Event Sourcing in Laravel: Aggregates &amp; Projectors | 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. Event Sourcing in Laravel: Aggregates, Projectors, and Rebuilding Read Models

 Event Sourcing in Laravel: Aggregates, Projectors, and Rebuilding Read Models
==============================================================================

 A practical walkthrough of event sourcing in Laravel — defining aggregates, persisting domain events, building projectors, and safely rebuilding read models without downtime.

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

ShareCopy linkCopied

 ![Event Sourcing in Laravel: Aggregates, Projectors, and Rebuilding Read Models](https://cdn.msaied.com/647/586a0f822614fed8091917a895ebc502.png) 

  On this page +1. [Why Event Sourcing Is Worth the Complexity](#why-event-sourcing-is-worth-the-complexity)
2. [Defining a Domain Event](#defining-a-domain-event)
3. [The Aggregate Root](#the-aggregate-root)
4. [The Event Store](#the-event-store)
5. [Projectors: Building Read Models](#projectors-building-read-models)
6. [Rebuilding Read Models Safely](#rebuilding-read-models-safely)
7. [Packages Worth Knowing](#packages-worth-knowing)
8. [Key Takeaways](#key-takeaways)

 Why Event Sourcing Is Worth the Complexity
------------------------------------------

Event sourcing replaces mutable row updates with an append-only log of domain events. Your database never forgets what happened — it only records *that* it happened. The current state is a projection of that history. For audit-heavy, financially sensitive, or highly collaborative domains, this is not over-engineering; it is the correct model.

This article focuses on the mechanics: aggregates, the event store, projectors, and the rebuild story.

---

Defining a Domain Event
-----------------------

Domain events are plain, immutable value objects. Use PHP 8.2+ readonly classes:

```php
final readonly class MoneyDeposited
{
    public function __construct(
        public string $accountId,
        public int    $amountCents,
        public string $currency,
        public \DateTimeImmutable $occurredAt,
    ) {}
}

```

No Eloquent, no infrastructure concerns — just data.

---

The Aggregate Root
------------------

An aggregate root applies events to itself and records them for persistence. It never touches the database directly.

```php
final class BankAccount
{
    private int $balanceCents = 0;
    private array $recordedEvents = [];

    public static function open(string $id, int $initialDeposit): self
    {
        $account = new self($id);
        $account->apply(new MoneyDeposited($id, $initialDeposit, 'USD', new \DateTimeImmutable()));
        return $account;
    }

    public function deposit(int $amountCents): void
    {
        if ($amountCents apply(new MoneyDeposited($this->id, $amountCents, 'USD', new \DateTimeImmutable()));
    }

    private function apply(object $event): void
    {
        $this->recordedEvents[] = $event;
        $this->when($event);
    }

    private function when(object $event): void
    {
        match (true) {
            $event instanceof MoneyDeposited => $this->balanceCents += $event->amountCents,
            default => null,
        };
    }

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

```

The `when()` method is the state machine. It is also used during reconstitution — replaying stored events rebuilds the aggregate without hitting any read model.

---

The Event Store
---------------

Store events as serialised JSON rows, never update them:

```php
Schema::create('stored_events', function (Blueprint $table) {
    $table->id();
    $table->string('aggregate_id')->index();
    $table->string('aggregate_type');
    $table->string('event_class');
    $table->jsonb('payload');
    $table->unsignedBigInteger('version');
    $table->timestamp('created_at');
    $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 produce a database error, not silent data corruption.

---

Projectors: Building Read Models
--------------------------------

A projector listens to stored events and maintains a denormalised read model:

```php
final class AccountBalanceProjector
{
    public function onMoneyDeposited(MoneyDeposited $event): void
    {
        AccountBalance::updateOrCreate(
            ['account_id' => $event->accountId],
            ['balance_cents' => \DB::raw("balance_cents + {$event->amountCents}")],
        );
    }
}

```

Projectors are side-effect machines. Keep them thin — no business logic, no conditionals beyond routing.

---

Rebuilding Read Models Safely
-----------------------------

This is the killer feature. When your read model schema changes, replay all events:

```php
final class RebuildAccountBalances extends Command
{
    protected $signature = 'projections:rebuild-balances';

    public function handle(EventStore $store, AccountBalanceProjector $projector): void
    {
        AccountBalance::truncate();

        $store->allForType(BankAccount::class)
            ->lazy()
            ->each(function (StoredEvent $stored) use ($projector) {
                $event = $stored->toEvent();
                if ($event instanceof MoneyDeposited) {
                    $projector->onMoneyDeposited($event);
                }
            });

        $this->info('Rebuild complete.');
    }
}

```

For zero-downtime rebuilds, write to a shadow table, verify, then swap with a transaction and a view rename.

---

Packages Worth Knowing
----------------------

`spatie/laravel-event-sourcing` provides a solid, production-tested foundation — aggregate roots, projectors, reactors, and a stored events table out of the box. Roll your own only if you have constraints it cannot satisfy.

---

Key Takeaways
-------------

- **Aggregates own business rules**; they never touch persistence directly.
- **Events are immutable facts** — readonly classes enforce this at the language level.
- **Optimistic concurrency** via a unique `(aggregate_id, version)` index prevents split-brain writes.
- **Projectors are disposable** — the event log is the truth; read models are derived and rebuildable.
- **Lazy collection replay** keeps memory flat during large rebuilds.
- Shadow-table rebuilds enable schema migrations without downtime.

- [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)
- [eloquent](https://www.msaied.com/public/articles?search=eloquent)
- [architecture](https://www.msaied.com/public/articles?search=architecture)

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

  Do I need event sourcing for every Laravel application?No. Event sourcing adds real complexity — serialisation, replay logic, projector maintenance. It pays off in domains where audit history, temporal queries, or complex state machines are core requirements. For a standard CRUD app, it is overkill.

   How do I handle aggregate reconstitution from the event store?Load all stored events for a given aggregate ID ordered by version, then replay them through the aggregate's `when()` method without calling `apply()` (which would re-record them). The final in-memory state is the current aggregate state.

   What happens if a projector fails mid-rebuild?Track the last successfully processed event ID in a checkpoint table. On retry, resume from that checkpoint rather than replaying from the beginning. This makes rebuilds idempotent and safe to interrupt.

   ![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 articleQueue totalSize() and JobInterrupted Event in Laravel 13.31](https://www.msaied.com/public/articles/queue-totalsize-and-jobinterrupted-event-in-laravel-1331) [Next articlePostgreSQL Window Functions in Laravel: Ranking, Running Totals, and Gap Detection](https://www.msaied.com/public/articles/postgresql-window-functions-in-laravel-ranking-running-totals-and-gap-detection-2)  

   On this page
-------------

1. [Why Event Sourcing Is Worth the Complexity](#why-event-sourcing-is-worth-the-complexity)
2. [Defining a Domain Event](#defining-a-domain-event)
3. [The Aggregate Root](#the-aggregate-root)
4. [The Event Store](#the-event-store)
5. [Projectors: Building Read Models](#projectors-building-read-models)
6. [Rebuilding Read Models Safely](#rebuilding-read-models-safely)
7. [Packages Worth Knowing](#packages-worth-knowing)
8. [Key Takeaways](#key-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)
