Event Sourcing in Laravel: Aggregates, Projectors, and Reactors Without the Framework Tax
#laravel #event-sourcing #cqrs #ddd #architecture

Event Sourcing in Laravel: Aggregates, Projectors, and Reactors Without the Framework Tax

4 min read Mohamed Said Mohamed Said

Why Roll Your Own Event Sourcing Core?

Packages like spatie/laravel-event-sourcing are excellent starting points, but they introduce conventions that can feel opaque at scale. Understanding the primitives — aggregates, an event store, projectors, and reactors — lets you make deliberate trade-offs instead of inheriting someone else's.

This article builds a minimal, production-ready event sourcing kernel in plain Laravel.


The Event Store: One Table, Append-Only

// database/migrations/xxxx_create_stored_events_table.php
Schema::create('stored_events', function (Blueprint $table) {
    $table->id();
    $table->uuid('aggregate_uuid')->index();
    $table->string('aggregate_version');
    $table->string('event_class');
    $table->json('payload');
    $table->timestamp('recorded_at', 6)->useCurrent();

    $table->unique(['aggregate_uuid', 'aggregate_version']);
});

The unique constraint on (aggregate_uuid, aggregate_version) is your optimistic concurrency guard — the database rejects duplicate versions, preventing split-brain writes without a distributed lock.


Aggregate Root: Pure, Stateful, Persistence-Ignorant

abstract class AggregateRoot
{
    private array $recordedEvents = [];
    protected int $aggregateVersion = 0;

    public static function retrieve(string $uuid): static
    {
        $instance = new static($uuid);
        $events = StoredEvent::forAggregate($uuid)->get();

        foreach ($events as $stored) {
            $instance->apply($stored->toDomainEvent());
            $instance->aggregateVersion = $stored->aggregate_version;
        }

        return $instance;
    }

    protected function recordThat(DomainEvent $event): void
    {
        $this->apply($event);
        $this->recordedEvents[] = $event;
    }

    private function apply(DomainEvent $event): void
    {
        $method = 'apply' . class_basename($event);
        if (method_exists($this, $method)) {
            $this->$method($event);
        }
    }

    public function persist(): void
    {
        foreach ($this->recordedEvents as $event) {
            $this->aggregateVersion++;
            StoredEvent::create([
                'aggregate_uuid'    => $this->uuid,
                'aggregate_version' => $this->aggregateVersion,
                'event_class'       => get_class($event),
                'payload'           => $event->toPayload(),
            ]);

            event($event); // dispatch to projectors/reactors
        }

        $this->recordedEvents = [];
    }
}

The aggregate never touches the database directly — retrieve and persist are the only seams. Business logic lives in concrete subclasses.


A Concrete Aggregate

class OrderAggregate extends AggregateRoot
{
    public OrderStatus $status = OrderStatus::Pending;

    public function place(Money $total, CustomerId $customerId): static
    {
        $this->recordThat(new OrderPlaced($this->uuid, $total, $customerId));
        return $this;
    }

    public function cancel(string $reason): static
    {
        if ($this->status === OrderStatus::Shipped) {
            throw new \DomainException('Cannot cancel a shipped order.');
        }
        $this->recordThat(new OrderCancelled($this->uuid, $reason));
        return $this;
    }

    protected function applyOrderPlaced(OrderPlaced $event): void
    {
        $this->status = OrderStatus::Pending;
    }

    protected function applyOrderCancelled(OrderCancelled $event): void
    {
        $this->status = OrderStatus::Cancelled;
    }
}

Guard clauses live in command methods. apply* methods are side-effect-free state transitions — never throw from them.


Projectors: Building Read Models

Projectors are standard Laravel listeners. Register them in EventServiceProvider:

class OrderProjector
{
    public function onOrderPlaced(OrderPlaced $event): void
    {
        OrderReadModel::create([
            'uuid'        => $event->aggregateUuid,
            'customer_id' => $event->customerId->value(),
            'total_cents' => $event->total->cents(),
            'status'      => 'pending',
        ]);
    }

    public function onOrderCancelled(OrderCancelled $event): void
    {
        OrderReadModel::where('uuid', $event->aggregateUuid)
            ->update(['status' => 'cancelled']);
    }
}

For replaying projections, iterate stored_events in chunks and re-dispatch events synchronously — no queue involved:

StoredEvent::query()
    ->where('event_class', OrderPlaced::class)
    ->chunkById(500, function ($chunk) use ($projector) {
        foreach ($chunk as $stored) {
            $projector->onOrderPlaced($stored->toDomainEvent());
        }
    });

Reactors: Async Side Effects

Reactors trigger external work — emails, webhooks, third-party APIs. They should always run on a queue:

class NotifyCustomerOnCancellation implements ShouldQueue
{
    public function handle(OrderCancelled $event): void
    {
        Mail::to($event->customerEmail)->send(new OrderCancelledMail($event));
    }
}

Because reactors are queued, a transient failure doesn't roll back your event store. Design them to be idempotent — check before acting, or use a processed-events log.


Key Takeaways

  • Optimistic concurrency via a unique DB constraint is simpler than distributed locks and sufficient for most workloads.
  • Aggregates are pure — no Eloquent, no HTTP, no side effects inside apply* methods.
  • Projectors replay deterministically by iterating the event store; keep them side-effect-free.
  • Reactors are queued and must be idempotent — the event store is the source of truth, not the side effect.
  • You can introduce event sourcing incrementally on a single aggregate without rewriting the whole application.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do I need a dedicated package like spatie/laravel-event-sourcing to do event sourcing in Laravel?
No. The primitives — an append-only stored_events table, an aggregate root class, and standard Laravel event listeners — are enough for most applications. Packages add convenience but also coupling; building the core yourself gives you full control over replay, snapshotting, and concurrency strategies.
Q02 How do I handle projection rebuilds without downtime?
Truncate the read model table and replay stored_events in chunks using chunkById, dispatching events synchronously to the projector. Run this as an Artisan command behind a maintenance window or use a shadow table strategy: rebuild into a new table, then swap it atomically with RENAME TABLE.
Q03 What is the difference between a projector and a reactor?
A projector builds or updates a read model (a database table you query). A reactor triggers a side effect in the outside world — sending an email, calling a webhook, or dispatching another job. Projectors should be deterministic and replayable; reactors must be idempotent because they run on a queue and may execute more than once.

Continue reading

More Articles

View all