Event Sourcing in Laravel: Aggregates, Projectors, and Rebuilding State from Events
#laravel #event-sourcing #ddd #cqrs #architecture

Event Sourcing in Laravel: Aggregates, Projectors, and Rebuilding State from Events

4 min read Mohamed Said Mohamed Said

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; avoid get() on large event tables.
  • Replay is your migration strategy: add a projector, replay, swap the query target.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do I need a package like spatie/laravel-event-sourcing to implement event sourcing in Laravel?
No. A package reduces boilerplate and adds snapshot support, but the core mechanics — an append-only stored_events table, aggregates that replay events, and projectors that build read models — can be implemented with plain Eloquent and Laravel's service container. Start without a package to understand the fundamentals, then adopt one if the project warrants it.
Q02 How do I handle schema changes to event payloads over time?
Store events as JSON and version your upcasters. When replaying, pass each raw payload through an upcaster chain before hydrating the event class. This lets you rename fields or restructure data without touching historical records. Keep upcasters small and composable — one per breaking change per event type.
Q03 Is replaying the entire event log safe in a live production environment?
Yes, if your projectors are idempotent and you replay into a shadow table or truncate the read model before starting. For zero-downtime deploys, build the new projection in a separate table, swap the query target atomically once replay finishes, then drop the old table.

Continue reading

More Articles

View all