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
==========================================================================================

 Skip the magic black boxes. Learn how to implement event sourcing in Laravel using plain PHP aggregates, Eloquent projectors, and queue-driven reactors — keeping full control over your event store.

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

ShareCopy linkCopied

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

  On this page +1. [Why Roll Your Own Event Sourcing Core?](#why-roll-your-own-event-sourcing-core)
2. [The Event Store: One Table, Append-Only](#the-event-store-one-table-append-only)
3. [Aggregate Root: Pure, Stateful, Persistence-Ignorant](#aggregate-root-pure-stateful-persistence-ignorant)
4. [A Concrete Aggregate](#a-concrete-aggregate)
5. [Projectors: Building Read Models](#projectors-building-read-models)
6. [Reactors: Async Side Effects](#reactors-async-side-effects)
7. [Key Takeaways](#key-takeaways)

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

Packages like `spatie/laravel-event-sourcing` are excellent starting points, but they introduce conventions that can feel opaque at scale. Understanding the primitives — aggregates, an event store, projectors, and reactors — lets you make deliberate trade-offs instead of inheriting someone else's.

This article builds a minimal, production-ready event sourcing kernel in plain Laravel.

---

The Event Store: One Table, Append-Only
---------------------------------------

```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_version');
    $table->string('event_class');
    $table->json('payload');
    $table->timestamp('recorded_at', 6)->useCurrent();

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

```

The `unique` constraint on `(aggregate_uuid, aggregate_version)` is your optimistic concurrency guard — the database rejects duplicate versions, preventing split-brain writes without a distributed lock.

---

Aggregate Root: Pure, Stateful, Persistence-Ignorant
----------------------------------------------------

```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) {
            $instance->apply($stored->toDomainEvent());
            $instance->aggregateVersion = $stored->aggregate_version;
        }

        return $instance;
    }

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

    private function apply(DomainEvent $event): void
    {
        $method = 'apply' . class_basename($event);
        if (method_exists($this, $method)) {
            $this->$method($event);
        }
    }

    public function persist(): void
    {
        foreach ($this->recordedEvents as $event) {
            $this->aggregateVersion++;
            StoredEvent::create([
                'aggregate_uuid'    => $this->uuid,
                'aggregate_version' => $this->aggregateVersion,
                'event_class'       => get_class($event),
                'payload'           => $event->toPayload(),
            ]);

            event($event); // dispatch to projectors/reactors
        }

        $this->recordedEvents = [];
    }
}

```

The aggregate never touches the database directly — `retrieve` and `persist` are the only seams. Business logic lives in concrete subclasses.

---

A Concrete Aggregate
--------------------

```php
class OrderAggregate extends AggregateRoot
{
    public OrderStatus $status = OrderStatus::Pending;

    public function place(Money $total, CustomerId $customerId): static
    {
        $this->recordThat(new OrderPlaced($this->uuid, $total, $customerId));
        return $this;
    }

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

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

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

```

Guard clauses live in command methods. `apply*` methods are side-effect-free state transitions — never throw from them.

---

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

Projectors are standard Laravel listeners. Register them in `EventServiceProvider`:

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

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

```

For **replaying** projections, iterate `stored_events` in chunks and re-dispatch events synchronously — no queue involved:

```php
StoredEvent::query()
    ->where('event_class', OrderPlaced::class)
    ->chunkById(500, function ($chunk) use ($projector) {
        foreach ($chunk as $stored) {
            $projector->onOrderPlaced($stored->toDomainEvent());
        }
    });

```

---

Reactors: Async Side Effects
----------------------------

Reactors trigger external work — emails, webhooks, third-party APIs. They should always run on a queue:

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

```

Because reactors are queued, a transient failure doesn't roll back your event store. Design them to be **idempotent** — check before acting, or use a processed-events log.

---

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

- **Optimistic concurrency** via a unique DB constraint is simpler than distributed locks and sufficient for most workloads.
- **Aggregates are pure** — no Eloquent, no HTTP, no side effects inside `apply*` methods.
- **Projectors replay deterministically** by iterating the event store; keep them side-effect-free.
- **Reactors are queued** and must be idempotent — the event store is the source of truth, not the side effect.
- You can introduce event sourcing incrementally on a single aggregate without rewriting the whole application.

- [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 dedicated package like spatie/laravel-event-sourcing to do event sourcing in Laravel?No. The primitives — an append-only stored\_events table, an aggregate root class, and standard Laravel event listeners — are enough for most applications. Packages add convenience but also coupling; building the core yourself gives you full control over replay, snapshotting, and concurrency strategies.

   How do I handle projection rebuilds without downtime?Truncate the read model table and replay stored\_events in chunks using chunkById, dispatching events synchronously to the projector. Run this as an Artisan command behind a maintenance window or use a shadow table strategy: rebuild into a new table, then swap it atomically with RENAME TABLE.

   What is the difference between a projector and a reactor?A projector builds or updates a read model (a database table you query). A reactor triggers a side effect in the outside world — sending an email, calling a webhook, or dispatching another job. Projectors should be deterministic and replayable; reactors must be idempotent because they run on a queue and may execute more than once.

   ![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 articleLivewire v3 Islands, Lazy Components, and Deferred Loading in Practice](https://www.msaied.com/public/articles/livewire-v3-islands-lazy-components-and-deferred-loading-in-practice) [Next articleModular Monolith in Laravel: Enforcing Bounded Contexts Without a Microservices Tax](https://www.msaied.com/public/articles/modular-monolith-in-laravel-enforcing-bounded-contexts-without-a-microservices-tax)  

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

1. [Why Roll Your Own Event Sourcing Core?](#why-roll-your-own-event-sourcing-core)
2. [The Event Store: One Table, Append-Only](#the-event-store-one-table-append-only)
3. [Aggregate Root: Pure, Stateful, Persistence-Ignorant](#aggregate-root-pure-stateful-persistence-ignorant)
4. [A Concrete Aggregate](#a-concrete-aggregate)
5. [Projectors: Building Read Models](#projectors-building-read-models)
6. [Reactors: Async Side Effects](#reactors-async-side-effects)
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)
