Event Sourcing in Laravel: Aggregates &amp; Projectors | Mohamed Said        [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://msaied.com) [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles  ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

       [  ](https://github.com/EG-Mohamed)       

 [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

  [ home ](https://msaied.com)    [ articles ](https://msaied.com/articles)    Event Sourcing in Laravel: Aggregates, Projectors, and Reactors Without the Framework 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)

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

  #laravel   #event-sourcing   #cqrs   #ddd   #architecture  

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

     21 Jun 2026      4 min read    ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said  

       Table of contents

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

 Found this useful?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fmsaied.com%2Farticles%2Fevent-sourcing-in-laravel-aggregates-projectors-and-reactors-without-the-framework-tax-1&text=Event+Sourcing+in+Laravel%3A+Aggregates%2C+Projectors%2C+and+Reactors+Without+the+Framework+Tax) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fmsaied.com%2Farticles%2Fevent-sourcing-in-laravel-aggregates-projectors-and-reactors-without-the-framework-tax-1) 

 Frequently Asked Questions 
----------------------------

  3 questions  

     Q01  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. 

      Q02  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. 

      Q03  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. 

  Continue reading

 More Articles 
---------------

 [ View all    ](https://msaied.com/articles) 

 [ ![Laravel AI SDK: Tool-Calling Agents and Conversation Persistence](https://cdn.msaied.com/260/8c84f424e42da01993c9ba4b8eb19655.png) laravel ai agents 

### Laravel AI SDK: Tool-Calling Agents and Conversation Persistence

Build reliable tool-calling AI agents in Laravel using the Prism package. Learn how to wire tools, persist con...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 21 Jun 2026     3 min read  

  Read    

 ](https://msaied.com/articles/laravel-ai-sdk-tool-calling-agents-and-conversation-persistence) [ ![Laravel Livewire v3 Internals: Morph Markers, JS Hooks, and Alpine Integration](https://cdn.msaied.com/259/e8ce445f021c2b26ebe4dd5da50014f8.png) livewire laravel alpine 

### Laravel Livewire v3 Internals: Morph Markers, JS Hooks, and Alpine Integration

Go beyond the docs: understand how Livewire v3 diffs the DOM with morph markers, intercept the lifecycle with...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 21 Jun 2026     3 min read  

  Read    

 ](https://msaied.com/articles/laravel-livewire-v3-internals-morph-markers-js-hooks-and-alpine-integration) [ ![Laravel Package Development: Service Providers, Auto-Discovery, and Config Merging](https://cdn.msaied.com/258/673a80fa8e42ae375a4bba21bdcd92ea.png) laravel packages service-providers 

### Laravel Package Development: Service Providers, Auto-Discovery, and Config Merging

Build a production-ready Laravel package from scratch — covering service provider design, auto-discovery via c...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 21 Jun 2026     3 min read  

  Read    

 ](https://msaied.com/articles/laravel-package-development-service-providers-auto-discovery-and-config-merging-1) 

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

Explore

- [Home](https://msaied.com)
- [Projects](https://msaied.com/projects)
- [Articles](https://msaied.com/articles)
- [Certificates](https://msaied.com/certificates)
- [Contact](https://msaied.com#contact-section)

Connect

- [   hello@msaied.com ](mailto:hello@msaied.com)
- [   +20 109 461 9204 ](tel:+201094619204)

© 2026 Mohamed Said. All rights reserved.

 [  ](https://github.com/EG-Mohamed) [  ](https://www.linkedin.com/in/msaiedm/) [  ](https://wa.me/201094619204) [  ](mailto:hello@msaied.com) [  ](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
