Laravel Event Sourcing: Projections &amp; Snapshots | Mohamed Said       [Skip to content](#main)  [ ![](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png) Mohamed SaidLaravel Backend Engineer ](https://www.msaied.com/public) - [Home](https://www.msaied.com/public)
- [Projects](https://www.msaied.com/public/projects)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [About](https://www.msaied.com/public#about)

           [  Contact](https://www.msaied.com/public#contact) Menu 

Menu
----

Close 

 - [HomeStart here](https://www.msaied.com/public)
- [ProjectsCase studies](https://www.msaied.com/public/projects)
- [ArticlesEngineering notes](https://www.msaied.com/public/articles)
- [CertificatesCredentials](https://www.msaied.com/public/certificates)
- [AboutHow I work](https://www.msaied.com/public#about)
- [ContactGet in touch](https://www.msaied.com/public#contact)

  [Start a conversation](https://www.msaied.com/public#contact) [WhatsApp](https://wa.me/201094619204) [Email](mailto:hello@msaied.com) 

 1. [Home](https://www.msaied.com/public)
2. /
3. [Articles](https://www.msaied.com/public/articles)
4. /
5. Laravel Event Sourcing: Projections, Snapshots, and Replay Without the Framework Tax

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

 Event sourcing in Laravel without a heavy framework. Build lean projectors, snapshot aggregates at scale, and replay history safely — using plain PHP classes and Eloquent.

 ![](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp) [Mohamed Said](https://www.msaied.com/public#person) Published 19 Jul 2026 · Updated 19 Jul 2026 · 4 min read

ShareCopy linkCopied

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

  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)

 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.

- [laravel](https://www.msaied.com/public/articles?search=laravel)
- [event-sourcing](https://www.msaied.com/public/articles?search=event-sourcing)
- [ddd](https://www.msaied.com/public/articles?search=ddd)
- [architecture](https://www.msaied.com/public/articles?search=architecture)

 Frequently asked questions 
---------------------------

  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.

   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.

   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.

   ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)About the author
----------------

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

[About](https://www.msaied.com/public#about) [GitHub ↗](https://github.com/EG-Mohamed) [LinkedIn ↗](https://www.linkedin.com/in/msaiedm/) [WhatsApp ↗](https://wa.me/201094619204) [Email Address ↗](mailto:hello@msaied.com) [My CV ↗](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)  

   [Previous articleLaravel Broadcasting with Reverb: Building Typed Event Contracts for WebSocket Channels](https://www.msaied.com/public/articles/laravel-broadcasting-with-reverb-building-typed-event-contracts-for-websocket-channels) [Next articleCQRS in Laravel Without a Framework: Commands, Handlers, and Read Models That Stay Lean](https://www.msaied.com/public/articles/cqrs-in-laravel-without-a-framework-commands-handlers-and-read-models-that-stay-lean)  

   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)

 ###  Have a technical challenge?

 Tell me what you’re building. I reply within two working days.

[Start a conversation](https://www.msaied.com/public#contact) 

   Related articles
-----------------

 [ ![](https://cdn.msaied.com/740/cce86edc21eddcbdd2f2454fadaf9c70.png)  · 3 min read### The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware

5 Oct 2026 ](https://www.msaied.com/public/articles/the-pipeline-pattern-in-laravel-custom-pipelines-beyond-middleware-1) [ ![](https://cdn.msaied.com/739/2d6897fdcdcf090613f96f72a64b8a78.png)  · 4 min read### MySQL Full-Text Search in Laravel: Indexes, Relevance Scoring, and Boolean Mode

4 Oct 2026 ](https://www.msaied.com/public/articles/mysql-full-text-search-in-laravel-indexes-relevance-scoring-and-boolean-mode) [ ![](https://cdn.msaied.com/738/073696a3fefe18bec825beec5ac658f5.png)  · 4 min read### Laravel Queue Rate-Limited Middleware: Throttling Jobs Without Losing Work

4 Oct 2026 ](https://www.msaied.com/public/articles/laravel-queue-rate-limited-middleware-throttling-jobs-without-losing-work) 

  Have a technical challenge?
----------------------------

Tell me what you’re building. I reply within two working days.

 [Discuss your project ↗](https://www.msaied.com/public#contact) 

  © 2026 Mohamed Said · Built with Laravel, meant to last.Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

 - [Home](https://www.msaied.com/public)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [GitHub](https://github.com/EG-Mohamed)
- [LinkedIn](https://www.linkedin.com/in/msaiedm/)
- [WhatsApp](https://wa.me/201094619204)
- [Email Address](mailto:hello@msaied.com)
- [My CV](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
- [Sitemap](https://www.msaied.com/public/sitemap.xml)
