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) - [Home](https://www.msaied.com)
- [Projects](https://www.msaied.com/projects)
- [Articles](https://www.msaied.com/articles)
- [Certificates](https://www.msaied.com/certificates)
- [About](https://www.msaied.com#about)

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

Menu
----

Close 

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

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

 1. [Home](https://www.msaied.com)
2. /
3. [Articles](https://www.msaied.com/articles)
4. /
5. Event Sourcing in Laravel: Aggregates, Projectors, and Reactors Without a Framework

 Event Sourcing in Laravel: Aggregates, Projectors, and Reactors Without a Framework
====================================================================================

 Build a lean event-sourced system in Laravel using plain PHP classes, Eloquent event streams, and synchronous projectors — no heavy package required. Practical patterns for aggregates, snapshots, and read-model rebuilds.

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

ShareCopy linkCopied

 ![Event Sourcing in Laravel: Aggregates, Projectors, and Reactors Without a Framework](https://cdn.msaied.com/789/40fed96a77e341e8fdd2b49f888d1d92.png) 

  On this page +1. [Why Roll Your Own Event Sourcing?](#why-roll-your-own-event-sourcing)
2. [The Event Stream Table](#the-event-stream-table)
3. [The Aggregate Root](#the-aggregate-root)
4. [A Concrete Aggregate](#a-concrete-aggregate)
5. [Projectors and Reactors](#projectors-and-reactors)
6. [Rebuilding Read Models](#rebuilding-read-models)
7. [Key Takeaways](#key-takeaways)

 Why Roll Your Own Event Sourcing?
---------------------------------

Packages like `spatie/laravel-event-sourcing` are excellent, but they carry opinions about UUIDs, snapshots, and async reactors that don't always fit. Understanding the primitives lets you adopt only what your domain needs and debug confidently when things go wrong.

This article builds a minimal, production-viable event-sourced `Order` aggregate from scratch.

---

The Event Stream Table
----------------------

Every aggregate's history lives in a single `event_streams` table:

```php
Schema::create('event_streams', function (Blueprint $table) {
    $table->id();
    $table->string('aggregate_type');
    $table->uuid('aggregate_id')->index();
    $table->unsignedInteger('version');
    $table->string('event_type');
    $table->json('payload');
    $table->timestamp('recorded_at', 6)->useCurrent();

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

```

The `unique` constraint on `(aggregate_id, version)` is your optimistic concurrency guard — a concurrent write at the same version will throw a `QueryException` you can catch and retry.

---

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

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

    public static function reconstruct(string $id): static
    {
        $aggregate = new static($id);

        EventStream::where('aggregate_id', $id)
            ->orderBy('version')
            ->each(fn ($row) => $aggregate->apply(
                EventSerializer::deserialize($row->event_type, $row->payload),
                persist: false
            ));

        return $aggregate;
    }

    protected function recordThat(DomainEvent $event): void
    {
        $this->apply($event, persist: true);
    }

    private function apply(DomainEvent $event, bool $persist): void
    {
        $method = 'apply' . class_basename($event);
        if (method_exists($this, $method)) {
            $this->$method($event);
        }
        if ($persist) {
            $this->recordedEvents[] = $event;
        }
        $this->version++;
    }

    public function persist(): void
    {
        foreach ($this->recordedEvents as $event) {
            EventStream::create([
                'aggregate_type' => static::class,
                'aggregate_id'   => $this->id,
                'version'        => $this->version - count($this->recordedEvents)
                                     + array_search($event, $this->recordedEvents),
                'event_type'     => $event::class,
                'payload'        => $event->toArray(),
            ]);
        }
        $this->recordedEvents = [];
    }
}

```

### A Concrete Aggregate

```php
class Order extends AggregateRoot
{
    public OrderStatus $status;

    public function place(CustomerId $customer, Money $total): void
    {
        $this->recordThat(new OrderPlaced($this->id, $customer, $total));
    }

    public function cancel(string $reason): void
    {
        if ($this->status === OrderStatus::Shipped) {
            throw new \DomainException('Cannot cancel a shipped order.');
        }
        $this->recordThat(new OrderCancelled($this->id, $reason));
    }

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

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

```

Notice: **no Eloquent in the aggregate**. State mutation happens only through `apply*` methods, keeping replay deterministic.

---

Projectors and Reactors
-----------------------

A **projector** builds a read model; a **reactor** triggers side effects.

```php
class OrderSummaryProjector
{
    public function onOrderPlaced(OrderPlaced $event): void
    {
        OrderSummary::create([
            'id'     => $event->orderId,
            'status' => 'pending',
            'total'  => $event->total->amount,
        ]);
    }

    public function onOrderCancelled(OrderCancelled $event): void
    {
        OrderSummary::where('id', $event->orderId)
            ->update(['status' => 'cancelled']);
    }
}

```

Dispatch projectors synchronously inside a `DB::transaction` wrapping `persist()`:

```php
DB::transaction(function () use ($order) {
    $order->persist();
    // projectors run in same transaction — read model stays consistent
    app(EventBus::class)->dispatch($order->releaseEvents());
});

```

Reactors (emails, webhooks) should be queued **outside** the transaction to avoid holding locks.

---

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

When your projector logic changes, replay from the stream:

```php
EventStream::where('aggregate_type', Order::class)
    ->orderBy('id')
    ->lazy()
    ->each(function ($row) use ($projector) {
        $event = EventSerializer::deserialize($row->event_type, $row->payload);
        $method = 'on' . class_basename($event);
        if (method_exists($projector, $method)) {
            $projector->$method($event);
        }
    });

```

`lazy()` streams rows without loading the entire table into memory — critical for large streams.

---

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

- Store events in a single `event_streams` table with a `unique(aggregate_id, version)` constraint for optimistic concurrency.
- Aggregates mutate state only through `apply*` methods; never read from the database inside an aggregate.
- Projectors run synchronously inside the write transaction; reactors queue outside it.
- Use `lazy()` for replay to avoid memory exhaustion on large event streams.
- You don't need a package to start — add snapshots and async reactors only when profiling proves you need them.

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

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

  When should I add snapshots to my event-sourced aggregate?Only when reconstruction latency becomes measurable — typically beyond a few hundred events per aggregate. Profile first; premature snapshots add complexity without benefit.

   How do I handle projector failures mid-replay without corrupting the read model?Wrap the entire replay in a transaction and truncate the read-model table before starting. If the replay fails, roll back and the old read model remains intact until you fix the projector and retry.

   Can I use this pattern alongside traditional Eloquent models in the same application?Yes. Event sourcing is best applied to the core domain aggregates where history matters. Peripheral concerns like user preferences or audit logs can remain plain Eloquent models.

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

[Mohamed Said](https://www.msaied.com#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#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 articleRead/Write Splitting and Sticky Reads in Laravel: A Production Guide](https://www.msaied.com/articles/readwrite-splitting-and-sticky-reads-in-laravel-a-production-guide-2)  

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

1. [Why Roll Your Own Event Sourcing?](#why-roll-your-own-event-sourcing)
2. [The Event Stream Table](#the-event-stream-table)
3. [The Aggregate Root](#the-aggregate-root)
4. [A Concrete Aggregate](#a-concrete-aggregate)
5. [Projectors and Reactors](#projectors-and-reactors)
6. [Rebuilding Read Models](#rebuilding-read-models)
7. [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#contact) 

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

 [ ![](https://cdn.msaied.com/788/efb3e90bd9347b097a81544cadd32b67.png)  · 4 min read### Read/Write Splitting and Sticky Reads in Laravel: A Production Guide

17 Oct 2026 ](https://www.msaied.com/articles/readwrite-splitting-and-sticky-reads-in-laravel-a-production-guide-2) [ ![](https://cdn.msaied.com/787/3dbb2870a99a7e4609f817339f391a5e.png) Laravel · 4 min read### Fluent Rule Builders for All Laravel Validation Rules

16 Oct 2026 ](https://www.msaied.com/articles/fluent-rule-builders-for-all-laravel-validation-rules) [ ![](https://cdn.msaied.com/782/215fd94331f880a70b7bfaf16528790d.png) Composer Pacakge · 4 min read### Laravel URL AI Transformer: Extract Structured Data From URLs

14 Oct 2026 ](https://www.msaied.com/articles/laravel-url-ai-transformer-extract-structured-data-from-urls) 

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

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

 [Discuss your project ↗](https://www.msaied.com#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)
- [Articles](https://www.msaied.com/articles)
- [Certificates](https://www.msaied.com/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/sitemap.xml)
