Why Event Sourcing Fits Laravel Better Than You Think
Event sourcing replaces mutable row updates with an append-only log of domain events. Your current state is a projection of that log. Laravel's queue system, Eloquent, and service container make this surprisingly ergonomic — you don't need a dedicated framework to get started.
This article focuses on three concrete pieces: aggregates that emit events, projectors that build read models, and replaying the event stream safely in production.
The Event Store
Start with a single stored_events table:
Schema::create('stored_events', function (Blueprint $table) {
$table->id();
$table->uuid('aggregate_uuid')->index();
$table->string('aggregate_type');
$table->string('event_class');
$table->json('payload');
$table->unsignedInteger('aggregate_version');
$table->timestamps();
$table->unique(['aggregate_uuid', 'aggregate_version']);
});
The unique constraint on (aggregate_uuid, aggregate_version) is your optimistic concurrency guard — two concurrent writes for the same version will throw, not silently overwrite.
Defining an Aggregate
An aggregate reconstitutes itself by replaying its own events:
final class OrderAggregate
{
private OrderStatus $status = OrderStatus::Pending;
private int $version = 0;
private array $pendingEvents = [];
public static function reconstitute(string $uuid): self
{
$aggregate = new self();
$events = StoredEvent::forAggregate($uuid)->get();
foreach ($events as $stored) {
$event = $stored->toEvent();
$aggregate->apply($event);
$aggregate->version = $stored->aggregate_version;
}
return $aggregate;
}
public function place(CustomerId $customer, Money $total): void
{
if ($this->status !== OrderStatus::Pending) {
throw new \DomainException('Order already placed.');
}
$this->recordThat(new OrderPlaced($customer, $total));
}
private function recordThat(object $event): void
{
$this->apply($event);
$this->pendingEvents[] = $event;
}
private function apply(object $event): void
{
match (true) {
$event instanceof OrderPlaced => $this->status = OrderStatus::Active,
$event instanceof OrderCancelled => $this->status = OrderStatus::Cancelled,
default => null,
};
}
public function persist(string $uuid): void
{
foreach ($this->pendingEvents as $event) {
$this->version++;
StoredEvent::create([
'aggregate_uuid' => $uuid,
'aggregate_type' => self::class,
'event_class' => $event::class,
'payload' => $event->toArray(),
'aggregate_version' => $this->version,
]);
}
$this->pendingEvents = [];
}
}
The aggregate never touches a read model. It only cares about its own invariants.
Projectors as Listeners
A projector listens to stored events and builds a denormalized read model:
final class OrderSummaryProjector
{
public function onOrderPlaced(OrderPlaced $event, string $aggregateUuid): void
{
OrderSummary::create([
'uuid' => $aggregateUuid,
'customer_id' => $event->customerId->value,
'total_cents' => $event->total->cents,
'status' => 'active',
]);
}
public function onOrderCancelled(OrderCancelled $event, string $aggregateUuid): void
{
OrderSummary::where('uuid', $aggregateUuid)
->update(['status' => 'cancelled']);
}
}
Wire projectors through a dispatcher that maps event_class to handler methods:
class EventDispatcher
{
public function __construct(private array $projectors) {}
public function dispatch(StoredEvent $stored): void
{
$event = $stored->toEvent();
$method = 'on' . class_basename($event);
foreach ($this->projectors as $projector) {
if (method_exists($projector, $method)) {
$projector->$method($event, $stored->aggregate_uuid);
}
}
}
}
Replaying the Event Stream
When you add a new projector or fix a bug in an existing one, truncate the read model table and replay:
class ReplayProjector extends Command
{
protected $signature = 'events:replay {projector}';
public function handle(EventDispatcher $dispatcher): void
{
$class = $this->argument('projector');
app($class)->reset(); // truncate read model
StoredEvent::query()
->orderBy('id')
->each(fn (StoredEvent $e) => $dispatcher->dispatch($e));
$this->info('Replay complete.');
}
}
Use each() rather than get() to avoid loading the entire event log into memory. For very large streams, cursor() or chunked processing is preferable.
Key Takeaways
- The unique
(aggregate_uuid, aggregate_version)index is your concurrency guard — never skip it. - Aggregates reconstitute from their own slice of the event log; they never query read models.
- Projectors are side-effect handlers — keep them idempotent so replay is safe.
StoredEvent::each()streams rows one at a time; avoidget()on large event tables.- Replay is your migration strategy: add a projector, replay, swap the query target.