Laravel Event Sourcing: Projections, Snapshots, and Replay Without the Framework Tax
#laravel #event-sourcing #ddd #architecture

Laravel Event Sourcing: Projections, Snapshots, and Replay Without the Framework Tax

4 min read Mohamed Said Mohamed Said

Why Roll Lean Instead of Reaching for a Package

Spatie's laravel-event-sourcing is excellent, but it carries opinions about aggregate roots, stored events, and projectors that can feel heavy for teams who only need parts of the pattern. Understanding the mechanics first — then choosing a library — leads to better decisions.

This article builds a minimal but production-honest event sourcing kernel: an append-only event store, a replayable projector, aggregate snapshots, and a safe replay strategy.


The Event Store

The foundation is a single append-only table. Never update or delete rows.

Schema::create('domain_events', function (Blueprint $table) {
    $table->id();
    $table->uuid('aggregate_id')->index();
    $table->string('aggregate_type');
    $table->unsignedInteger('version');
    $table->string('event_type');
    $table->jsonb('payload');
    $table->timestamp('occurred_at', 6)->useCurrent();

    $table->unique(['aggregate_id', 'version']); // optimistic concurrency
});

The unique constraint on (aggregate_id, version) is your optimistic concurrency guard. Two concurrent writes for the same version will throw a QueryException — catch it and surface a domain conflict.

final class EventStore
{
    public function append(string $aggregateId, string $type, DomainEvent $event, int $expectedVersion): void
    {
        DB::table('domain_events')->insert([
            'aggregate_id'   => $aggregateId,
            'aggregate_type' => $type,
            'version'        => $expectedVersion + 1,
            'event_type'     => $event::class,
            'payload'        => json_encode($event->toArray()),
            'occurred_at'    => now(),
        ]);
    }

    public function loadFrom(string $aggregateId, int $fromVersion = 0): Collection
    {
        return DB::table('domain_events')
            ->where('aggregate_id', $aggregateId)
            ->where('version', '>', $fromVersion)
            ->orderBy('version')
            ->get();
    }
}

Aggregates Without Magic

An aggregate records events internally and applies them to mutate state.

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

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

    abstract protected function apply(DomainEvent $event): void;

    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];
        return $events;
    }

    public function version(): int { return $this->version; }
}

Reconstitution replays stored events through apply() without re-recording them:

public static function reconstitute(Collection $storedEvents): static
{
    $aggregate = new static();
    foreach ($storedEvents as $row) {
        $eventClass = $row->event_type;
        $aggregate->apply($eventClass::fromArray(json_decode($row->payload, true)));
        $aggregate->version = $row->version;
    }
    return $aggregate;
}

Snapshots: Skip the Full Replay

For aggregates with thousands of events, replaying from zero is expensive. Snapshots cache state at a version checkpoint.

Schema::create('aggregate_snapshots', function (Blueprint $table) {
    $table->uuid('aggregate_id')->primary();
    $table->unsignedInteger('version');
    $table->jsonb('state');
    $table->timestamp('taken_at')->useCurrent();
});

Load the snapshot first, then only replay events after its version:

public function load(string $aggregateId): MyAggregate
{
    $snapshot = DB::table('aggregate_snapshots')
        ->where('aggregate_id', $aggregateId)
        ->first();

    $fromVersion = $snapshot?->version ?? 0;
    $events = $this->store->loadFrom($aggregateId, $fromVersion);

    if ($snapshot) {
        $aggregate = MyAggregate::fromSnapshot(json_decode($snapshot->state, true));
    } else {
        $aggregate = MyAggregate::reconstitute($events);
        return $aggregate;
    }

    return MyAggregate::reconstituteFrom($aggregate, $events);
}

Snapshot every N events (e.g., 50) inside your command handler after persisting.


Projectors and Safe Replay

A projector listens to stored events and builds a read model. Keep projectors idempotent — replay must be safe to run multiple times.

final class OrderSummaryProjector
{
    public function onOrderPlaced(OrderPlaced $event): void
    {
        DB::table('order_summaries')->upsert(
            ['order_id' => $event->orderId, 'status' => 'placed', 'total' => $event->total],
            ['order_id'],
            ['status', 'total']
        );
    }
}

For replay, truncate the read model table first, then stream events in chunks:

DB::table('order_summaries')->truncate();

DB::table('domain_events')
    ->where('event_type', OrderPlaced::class)
    ->orderBy('id')
    ->chunk(500, function ($rows) use ($projector) {
        foreach ($rows as $row) {
            $projector->onOrderPlaced(
                OrderPlaced::fromArray(json_decode($row->payload, true))
            );
        }
    });

Wrap replay in a queue job with a unique lock so two replays never race.


Takeaways

  • The unique(aggregate_id, version) constraint gives you optimistic concurrency for free at the DB level.
  • Snapshots are a performance concern, not a correctness concern — add them only when replay latency becomes measurable.
  • Projectors must be idempotent; upsert() is your friend.
  • Replay is a maintenance operation — run it in a queued job with a mutex, never in a request cycle.
  • You can adopt this pattern incrementally: start with one aggregate and one projector before committing to a full framework.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I use a snapshot versus always replaying from the beginning?
Snapshot when reconstitution latency becomes noticeable in production — typically when an aggregate accumulates hundreds of events. Measure first; premature snapshotting adds complexity without benefit.
Q02 How do I handle projector schema changes when replaying old events?
Version your event payloads with an upcaster: a small transformer that converts old payload shapes to the current schema before the projector sees them. Keep upcasters in a chain so each handles exactly one version transition.
Q03 Is it safe to dispatch Laravel jobs from inside a projector during replay?
No. During replay, side-effects like emails or external API calls must be suppressed. Use a replay flag (e.g., a singleton boolean in the container) that projectors check before dispatching any secondary effects.

Continue reading

More Articles

View all