Event Sourcing Concepts: Building Immutable Data Streams in Laravel
Master event sourcing concepts in Laravel. Learn to move beyond CRUD by using immutable event streams and projections to reconstruct domain state reliably.
Previously in this course, we explored Introduction to Laravel Events and Listeners for Clean Code, where we used events to trigger side effects like sending emails or updating secondary tables. In this lesson, we shift our perspective from using events as notifications to using them as the source of truth.
Understanding Event Sourcing Principles
In a standard CRUD (Create, Read, Update, Delete) application, you store the current state of an object. If you update a task's title, the old title is gone, overwritten in the database.
Event sourcing reverses this. Instead of storing the current state, you store a sequence of immutable events—the "Event Stream"—that describe every change that has ever occurred. To find the current state of a task, you "replay" these events from the beginning.
| Concept | Traditional CRUD | Event Sourcing |
|---|---|---|
| Storage | Current state only | Sequence of events |
| History | Usually lost or requires logs | Always available (reconstructible) |
| Immutability | Mutable rows | Append-only events |
| Complexity | Low | High (requires projections) |
Defining Events and Streams
An event is a statement of fact about something that happened in the past. In our project board, instead of a tasks table with a status column, we might have events like TaskCreated, TaskStatusChanged, and TaskAssigned.
An event stream is the ordered list of all events associated with a specific aggregate (e.g., one specific Task).
Implementing a Simple Event Projection
A projection is the process of taking an event stream and "projecting" it into a read model. This is the state we actually display to the user.
Let's implement a basic projection for a Task entity.
PHP#6A9955">// 1. The Event(Fact) class TaskStatusChanged { public function __construct( public readonly int $taskId, public readonly string $newStatus, public readonly DateTimeImmutable $occurredAt ) {} } #6A9955">// 2. The Projection(Reconstructing State) class TaskProjector { public function project(int $taskId, array $events): array { $state = ['status' => 'pending']; foreach ($events as $event) { if ($event instanceof TaskStatusChanged) { $state['status'] = $event->newStatus; } } return $state; } }
By storing these events in a table (e.g., event_store), you can replay them to reconstruct the state of any task at any point in time. This is why API Architecture Audit Logs: Implementing Immutable Event Sourcing is a natural pattern here; the audit log is your database.
Hands-on Exercise: Projecting Task Completion
In your project board, create a new TaskCompleted event.
- Create a class
TaskCompletedthat holds thetaskIdandcompletedAt. - Update the
TaskProjectorto handle this event: if aTaskCompletedevent is encountered, set thestatusto 'completed' and record thecompleted_attimestamp in your$statearray. - Test this by passing an array of
[TaskCreated, TaskStatusChanged, TaskCompleted]into your projector and asserting that the resulting array reflects the final completed status.
Common Pitfalls
- Event Schema Evolution: If you rename a property in an event class, old events in your database will break your projector. Always keep your event classes stable or implement versioning/upcasting logic.
- Performance: Replaying thousands of events every time you load a page is slow. Use "Snapshots"—periodically save the state of an aggregate to a table so you only have to replay events since the last snapshot.
- Eventual Consistency: Since the read model is built from events, it may lag slightly behind the event store. Ensure your UI handles this, perhaps by optimistic updates.
Advancing the Running Project
For our project board, we will now prepare to store these events. Create a migration for an event_store table:
PHPSchema::create('event_store', function (Blueprint $table) { $table->id(); $table->string('aggregate_type'); #6A9955">// e.g., 'Task' $table->string('aggregate_id'); $table->string('event_type'); $table->json('payload'); $table->timestamp('created_at'); });
This table will eventually hold the history of our tasks, providing the foundation for the complex features we'll build later.
Recap
We've moved from storing "what is" to "what happened." By using immutable events as the source of truth and projections to calculate current state, we gain an audit trail by default and a flexible way to change our business logic without losing historical context.
Up next: We will explore how to manage job chains to ensure our projections are updated reliably in the background, keeping our read models in sync with our event store.
Work with me

Laravel REST API Development
Clean, secure, well-documented Laravel REST APIs — the backend engine for your app, mobile client, or SaaS. Built by an API specialist.

FilamentPHP Admin Panel & Dashboard Development
A powerful admin panel for your Laravel app — built with FilamentPHP so you can manage everything without touching the database.