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 Reactors Without the Framework Tax

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

 Event sourcing sounds heavy until you implement it yourself. This guide walks through aggregates, projectors, and reactors in plain Laravel — no magic package required — with real code and honest trade-offs.

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

ShareCopy linkCopied

 ![Event Sourcing in Laravel: Aggregates, Projectors, and Reactors Without the Framework Tax](https://cdn.msaied.com/187/5b670c397535e780af70790e5d45ca19.png) 

  On this page +1. [Why Roll Your Own (At Least Partially)](#why-roll-your-own-at-least-partially)
2. [The Event Store](#the-event-store)
3. [The Aggregate Root](#the-aggregate-root)
4. [Projectors: Building Read Models](#projectors-building-read-models)
5. [Reactors: Side Effects in Isolation](#reactors-side-effects-in-isolation)
6. [Replaying Projections](#replaying-projections)
7. [Key Takeaways](#key-takeaways)

 Why Roll Your Own (At Least Partially)
--------------------------------------

Packages like `spatie/laravel-event-sourcing` are excellent, but they carry opinions about storage, snapshots, and aggregate retrieval that can fight your domain. Understanding the primitives lets you adopt a package selectively — or build a lightweight version that fits your bounded context perfectly.

This article focuses on three concepts: **aggregates** (the write side), **projectors** (read-model builders), and **reactors** (side-effect handlers).

---

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

Every event needs a durable home before anything else happens.

```php
// database/migrations/xxxx_create_stored_events_table.php
Schema::create('stored_events', function (Blueprint $table) {
    $table->id();
    $table->uuid('aggregate_uuid')->index();
    $table->string('aggregate_type');
    $table->string('event_class');
    $table->jsonb('payload');
    $table->unsignedBigInteger('aggregate_version');
    $table->timestamps();

    $table->unique(['aggregate_uuid', 'aggregate_version']);
});

```

The `unique` constraint on `(aggregate_uuid, aggregate_version)` is your optimistic concurrency guard — two concurrent writes for the same version will produce a database-level conflict rather than silent data corruption.

---

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

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

    public static function retrieve(string $uuid): static
    {
        $instance = new static($uuid);
        $events = StoredEvent::forAggregate($uuid)->get();

        foreach ($events as $stored) {
            $event = unserialize($stored->payload['serialized']);
            $instance->apply($event);
            $instance->aggregateVersion = $stored->aggregate_version;
        }

        return $instance;
    }

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

    public function persist(): void
    {
        foreach ($this->recordedEvents as $event) {
            $this->aggregateVersion++;
            StoredEvent::create([
                'aggregate_uuid'    => $this->uuid,
                'aggregate_type'    => static::class,
                'event_class'       => $event::class,
                'payload'           => ['serialized' => serialize($event)],
                'aggregate_version' => $this->aggregateVersion,
            ]);
            event($event); // dispatch to projectors & reactors
        }
        $this->recordedEvents = [];
    }

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

```

A concrete aggregate looks like this:

```php
final class OrderAggregate extends AggregateRoot
{
    private OrderStatus $status;

    public function place(CustomerId $customer, Money $total): void
    {
        // guard business rules here
        $this->recordThat(new OrderPlaced($this->uuid, $customer, $total));
    }

    public function cancel(string $reason): void
    {
        if ($this->status === OrderStatus::Shipped) {
            throw new CannotCancelShippedOrder();
        }
        $this->recordThat(new OrderCancelled($this->uuid, $reason));
    }

    protected function apply(DomainEvent $event): void
    {
        match (true) {
            $event instanceof OrderPlaced    => $this->status = OrderStatus::Pending,
            $event instanceof OrderCancelled => $this->status = OrderStatus::Cancelled,
            default => null,
        };
    }
}

```

---

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

Projectors are plain Laravel listeners. They rebuild query-optimised tables from events.

```php
final class OrderListProjector
{
    public function onOrderPlaced(OrderPlaced $event): void
    {
        OrderReadModel::create([
            'uuid'        => $event->orderId,
            'customer_id' => $event->customerId->value(),
            'total_cents' => $event->total->cents(),
            'status'      => 'pending',
        ]);
    }

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

```

Register it in `EventServiceProvider`:

```php
protected $listen = [
    OrderPlaced::class    => [OrderListProjector::class . '@onOrderPlaced'],
    OrderCancelled::class => [OrderListProjector::class . '@onOrderCancelled'],
];

```

---

Reactors: Side Effects in Isolation
-----------------------------------

Reactors handle side effects — emails, webhooks, third-party calls — and should always run asynchronously.

```php
final class NotifyCustomerOnCancellation implements ShouldQueue
{
    public function handle(OrderCancelled $event): void
    {
        Mail::to($event->customerEmail)->send(new OrderCancelledMail($event));
    }
}

```

Because reactors are queued, a mail failure never rolls back your aggregate state. That separation is the point.

---

Replaying Projections
---------------------

The killer feature of event sourcing is replay. Drop a corrupted read model and rebuild it:

```php
artisan make:command ReplayProjection

// inside handle()
StoredEvent::query()
    ->whereIn('event_class', [OrderPlaced::class, OrderCancelled::class])
    ->chunkById(500, function ($chunk) {
        foreach ($chunk as $stored) {
            $event = unserialize($stored->payload['serialized']);
            (new OrderListProjector())->{'on' . class_basename($event)}($event);
        }
    });

```

---

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

- **The unique version constraint** is your concurrency guard — don't skip it.
- **Aggregates own business rules**; projectors own read models; reactors own side effects. Keep them separate.
- **Replay is the payoff** — design projectors to be idempotent from day one.
- **Serialize carefully**: use versioned DTOs or JSON, not raw PHP `serialize()`, in production.
- **Start with one bounded context** before applying event sourcing everywhere.

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

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

  Do I need a package like spatie/laravel-event-sourcing to implement event sourcing in Laravel?No. The primitives — a stored\_events table, an aggregate root base class, and standard Laravel event listeners — are enough to get started. Packages add snapshots, async projectors, and tooling that become valuable at scale, but understanding the core first prevents you from fighting the package's assumptions.

   How do I handle aggregate version conflicts under concurrent writes?The unique index on (aggregate\_uuid, aggregate\_version) causes a database-level integrity exception on concurrent writes for the same version. Catch that exception at the application boundary and retry the command after re-retrieving the aggregate, similar to optimistic locking.

   Should projectors run synchronously or asynchronously?Projectors that build critical read models (e.g., the list your UI queries) should run synchronously so reads are consistent immediately after a write. Reactors that trigger side effects — emails, webhooks — should always be queued to isolate failures from your aggregate's write path.

   ![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 Horizon Deep Dive: Queue Tuning, Supervisor Strategies, and Job Reliability](https://www.msaied.com/public/articles/laravel-horizon-deep-dive-queue-tuning-supervisor-strategies-and-job-reliability) [Next articleHow Laravel Cloud's Scale to Zero Works: Checkpoint/Restore Explained](https://www.msaied.com/public/articles/how-laravel-clouds-scale-to-zero-works-checkpointrestore-explained)  

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

1. [Why Roll Your Own (At Least Partially)](#why-roll-your-own-at-least-partially)
2. [The Event Store](#the-event-store)
3. [The Aggregate Root](#the-aggregate-root)
4. [Projectors: Building Read Models](#projectors-building-read-models)
5. [Reactors: Side Effects in Isolation](#reactors-side-effects-in-isolation)
6. [Replaying Projections](#replaying-projections)
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/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)
