Event Sourcing and CQRS in Laravel: Aggregates, Projectors, and Eventual Consistency
#laravel #event-sourcing #cqrs #ddd #architecture

Event Sourcing and CQRS in Laravel: Aggregates, Projectors, and Eventual Consistency

4 min read Mohamed Said Mohamed Said

Why Event Sourcing Is Not Just a Fancy Audit Log

Most Laravel applications store current state. Event sourcing flips that: you store what happened, and derive state from the event stream. The audit log is a side-effect, not the goal. The goal is a system where every state transition is an explicit, replayable fact.

This article focuses on the mechanics — aggregates, domain events, projectors, and reactors — using spatie/laravel-event-sourcing as the runtime, but the concepts apply regardless of library.


The Aggregate Root

An aggregate root is the consistency boundary. All mutations go through it; it raises events instead of writing directly to a database.

use Spatie\EventSourcing\AggregateRoots\AggregateRoot;

final class OrderAggregate extends AggregateRoot
{
    private OrderStatus $status = OrderStatus::Pending;
    private int $totalCents = 0;

    public function place(array $items, int $totalCents): static
    {
        $this->recordThat(new OrderPlaced($items, $totalCents));
        return $this;
    }

    public function cancel(string $reason): static
    {
        if ($this->status !== OrderStatus::Pending) {
            throw new \DomainException('Only pending orders can be cancelled.');
        }
        $this->recordThat(new OrderCancelled($reason));
        return $this;
    }

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

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

The apply* methods rebuild state from the event stream. No Eloquent, no DB calls inside the aggregate.

Raising Events from a Command Handler

final class PlaceOrderHandler
{
    public function handle(PlaceOrderCommand $command): void
    {
        OrderAggregate::retrieve($command->orderId)
            ->place($command->items, $command->totalCents)
            ->persist();
    }
}

persist() writes the new events to the stored_events table and dispatches them to all registered projectors and reactors.


Projectors: Building Read Models

A projector listens to events and maintains a denormalized read model — the "Q" side of CQRS.

use Spatie\EventSourcing\EventHandlers\Projectors\Projector;

final class OrderSummaryProjector extends Projector
{
    public function onOrderPlaced(OrderPlaced $event, string $aggregateUuid): void
    {
        DB::table('order_summaries')->insert([
            'uuid'        => $aggregateUuid,
            'status'      => 'pending',
            'total_cents' => $event->totalCents,
            'created_at'  => now(),
        ]);
    }

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

Because projectors replay from the event log, you can drop the order_summaries table and rebuild it at any time — a superpower when requirements change.


Reactors: Side-Effects Without Coupling

Reactors handle side-effects (emails, webhooks, third-party calls). Unlike projectors, they are not replayed.

use Spatie\EventSourcing\EventHandlers\Reactors\Reactor;

final class NotifyCustomerOnCancellation extends Reactor
{
    public function onOrderCancelled(OrderCancelled $event, string $aggregateUuid): void
    {
        $order = OrderSummary::where('uuid', $aggregateUuid)->firstOrFail();
        Mail::to($order->customer_email)->send(new OrderCancelledMail($order));
    }
}

Snapshots for Long-Lived Aggregates

Replaying thousands of events on every command is expensive. Snapshots cache aggregate state at a point in time:

OrderAggregate::retrieve($uuid)
    ->snapshot(); // persists a snapshot; future retrieval starts from here

The library handles merging the snapshot with subsequent events automatically.


Honest Trade-offs

Event sourcing is not free:

  • Eventual consistency means your read models may lag behind writes. Design your UI accordingly.
  • Schema evolution is hard. Stored events are immutable; changing an event's shape requires upcasters.
  • Debugging requires tooling to inspect the event stream; tinker alone won't cut it.
  • Team onboarding cost is real — the pattern is unfamiliar to most Laravel developers.

Reserve it for domains where auditability, temporal queries, or event-driven integrations justify the overhead. A CRUD admin panel does not.


Key Takeaways

  • Aggregates enforce invariants and raise events; they never touch the database directly.
  • Projectors build read models and are fully replayable from the event log.
  • Reactors handle side-effects and run once — never on replay.
  • Snapshots prevent performance degradation on aggregates with long event histories.
  • Eventual consistency is a first-class concern, not an implementation detail to hide.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use event sourcing for only part of my Laravel application?
Yes, and that is often the right call. Apply event sourcing only to the bounded contexts where auditability, replayability, or complex domain logic justify the overhead. The rest of the app can remain standard Eloquent CRUD.
Q02 How do I handle schema changes to stored events over time?
Use event upcasters — classes that transform an old event payload into the current shape before it reaches your aggregate or projector. Spatie's package supports upcasters natively, letting you version events without mutating historical records.
Q03 Is there a performance cost to replaying aggregates on every command?
Yes. For aggregates with hundreds of events, replay latency is measurable. Mitigate it with snapshots, which cache aggregate state so only events after the snapshot need replaying.

Continue reading

More Articles

View all