Laravel Event Sourcing: Projections &amp; Snapshots | Mohamed Said        [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://msaied.com) [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles  ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

       [  ](https://github.com/EG-Mohamed)       

 [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

  [ home ](https://msaied.com)    [ articles ](https://msaied.com/articles)    Laravel Event Sourcing: Projections, Snapshots, and Replay Without the Framework Tax        On this page       1. [  Why Roll Lean Instead of Reaching for a Package ](#why-roll-lean-instead-of-reaching-for-a-package)
2. [  The Event Store ](#the-event-store)
3. [  Aggregates Without Magic ](#aggregates-without-magic)
4. [  Snapshots: Skip the Full Replay ](#snapshots-skip-the-full-replay)
5. [  Projectors and Safe Replay ](#projectors-and-safe-replay)
6. [  Takeaways ](#takeaways)

  ![Laravel Event Sourcing: Projections, Snapshots, and Replay Without the Framework Tax](https://cdn.msaied.com/445/b4f83e0b5da7c11e2fe942eebc1cad08.png)

  #laravel   #event-sourcing   #ddd   #architecture  

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

     19 Jul 2026      4 min read    ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said  

       Table of contents

1. [  01   Why Roll Lean Instead of Reaching for a Package  ](#why-roll-lean-instead-of-reaching-for-a-package)
2. [  02   The Event Store  ](#the-event-store)
3. [  03   Aggregates Without Magic  ](#aggregates-without-magic)
4. [  04   Snapshots: Skip the Full Replay  ](#snapshots-skip-the-full-replay)
5. [  05   Projectors and Safe Replay  ](#projectors-and-safe-replay)
6. [  06   Takeaways  ](#takeaways)

 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.

```php
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.

```php
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.

```php
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:

```php
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.

```php
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:

```php
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.

```php
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:

```php
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?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fmsaied.com%2Farticles%2Flaravel-event-sourcing-projections-snapshots-and-replay-without-the-framework-tax&text=Laravel+Event+Sourcing%3A+Projections%2C+Snapshots%2C+and+Replay+Without+the+Framework+Tax) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fmsaied.com%2Farticles%2Flaravel-event-sourcing-projections-snapshots-and-replay-without-the-framework-tax) 

 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    ](https://msaied.com/articles) 

 [ ![Laravel + Python FastAPI: Image OCR Demo](https://cdn.msaied.com/447/5f3d87146fbf19957973cbc88c6c0155.png) Laravel Python FastAPI 

### Laravel + Python FastAPI: Image OCR Demo

Learn how to call a Python image OCR script from Laravel using FastAPI as the bridge. This premium tutorial wa...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 20 Jul 2026     3 min read  

  Read    

 ](https://msaied.com/articles/laravel-python-fastapi-image-ocr-demo) [ ![CQRS in Laravel Without a Framework: Commands, Handlers, and Read Models That Stay Lean](https://cdn.msaied.com/446/a05febe70b72107387f210e0f9eae089.png) laravel cqrs architecture 

### CQRS in Laravel Without a Framework: Commands, Handlers, and Read Models That Stay Lean

Skip the heavy event-sourcing libraries. This guide shows how to implement a practical CQRS layer in Laravel u...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 20 Jul 2026     1 min read  

  Read    

 ](https://msaied.com/articles/cqrs-in-laravel-without-a-framework-commands-handlers-and-read-models-that-stay-lean) [ ![Laravel Broadcasting with Reverb: Building Typed Event Contracts for WebSocket Channels](https://cdn.msaied.com/444/e50bb90e38d98d08949583fdcf123e65.png) laravel reverb broadcasting 

### Laravel Broadcasting with Reverb: Building Typed Event Contracts for WebSocket Channels

Skip the stringly-typed broadcasting guesswork. Learn how to define strict PHP event contracts, scope them to...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 19 Jul 2026     3 min read  

  Read    

 ](https://msaied.com/articles/laravel-broadcasting-with-reverb-building-typed-event-contracts-for-websocket-channels) 

   [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://msaied.com)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

Explore

- [Home](https://msaied.com)
- [Projects](https://msaied.com/projects)
- [Articles](https://msaied.com/articles)
- [Certificates](https://msaied.com/certificates)
- [Contact](https://msaied.com#contact-section)

Connect

- [   hello@msaied.com ](mailto:hello@msaied.com)
- [   +20 109 461 9204 ](tel:+201094619204)

© 2026 Mohamed Said. All rights reserved.

 [  ](https://github.com/EG-Mohamed) [  ](https://www.linkedin.com/in/msaiedm/) [  ](https://wa.me/201094619204) [  ](mailto:hello@msaied.com) [  ](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
