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 Ceremony        On this page       1. [  Why Event Sourcing? The Honest Case ](#why-event-sourcing-the-honest-case)
2. [  Core Concepts in 60 Seconds ](#core-concepts-in-60-seconds)
3. [  Defining a Stored Event ](#defining-a-stored-event)
4. [  The Aggregate Root ](#the-aggregate-root)
5. [  Persisting via a Command ](#persisting-via-a-command)
6. [  Building a Projector ](#building-a-projector)
7. [  Reactors for Side Effects ](#reactors-for-side-effects)
8. [  Snapshot Support for Long-Lived Aggregates ](#snapshot-support-for-long-lived-aggregates)
9. [  Testing the Aggregate ](#testing-the-aggregate)
10. [  Key Takeaways ](#key-takeaways)

  ![Event Sourcing in Laravel: Aggregates, Projectors, and Reactors Without the Ceremony](https://cdn.msaied.com/545/14148532753288225b142923e6704a4d.png)

  #laravel   #event-sourcing   #ddd   #cqrs   #spatie  

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

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

       Table of contents

  10 sections  

1. [  01   Why Event Sourcing? The Honest Case  ](#why-event-sourcing-the-honest-case)
2. [  02   Core Concepts in 60 Seconds  ](#core-concepts-in-60-seconds)
3. [  03   Defining a Stored Event  ](#defining-a-stored-event)
4. [  04   The Aggregate Root  ](#the-aggregate-root)
5. [  05   Persisting via a Command  ](#persisting-via-a-command)
6. [  06   Building a Projector  ](#building-a-projector)
7. [  07   Reactors for Side Effects  ](#reactors-for-side-effects)
8. [  08   Snapshot Support for Long-Lived Aggregates  ](#snapshot-support-for-long-lived-aggregates)
9. [  09   Testing the Aggregate  ](#testing-the-aggregate)
10. [  10   Key Takeaways  ](#key-takeaways)

       Why Event Sourcing? The Honest Case
-----------------------------------

Most Laravel apps store *current state*. You `UPDATE accounts SET balance = 500` and the history is gone. Event sourcing flips this: you store *what happened* (`MoneyDeposited`, `MoneyWithdrawn`) and derive current state by replaying those events. The payoff is a built-in audit log, time-travel debugging, and the ability to build new read models from historical data without touching production writes.

This article focuses on the `spatie/laravel-event-sourcing` package — a pragmatic layer over raw event sourcing that fits naturally into a Laravel codebase.

Core Concepts in 60 Seconds
---------------------------

- **Stored Event** — a persisted domain event with metadata (aggregate UUID, version, created\_at).
- **Aggregate Root** — the write-side object that validates commands and records events.
- **Projector** — rebuilds a read model by replaying events (think: Eloquent table).
- **Reactor** — side-effects triggered by events (send email, dispatch job). Runs once, not on replay.

Defining a Stored Event
-----------------------

```php
// app/Domain/Wallet/Events/MoneyDeposited.php
use Spatie\EventSourcing\StoredEvents\ShouldBeStored;

final class MoneyDeposited implements ShouldBeStored
{
    public function __construct(
        public readonly int $amountInCents,
        public readonly string $currency,
    ) {}
}

```

Keep events as immutable value objects. No methods, no logic — just data.

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

```php
// app/Domain/Wallet/WalletAggregate.php
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;

final class WalletAggregate extends AggregateRoot
{
    private int $balanceInCents = 0;

    public function deposit(int $amountInCents, string $currency): static
    {
        if ($amountInCents recordThat(new MoneyDeposited($amountInCents, $currency));

        return $this;
    }

    protected function applyMoneyDeposited(MoneyDeposited $event): void
    {
        $this->balanceInCents += $event->amountInCents;
    }
}

```

`recordThat` stages the event. `apply*` methods mutate internal state. The aggregate never touches the database directly — that is the projector's job.

### Persisting via a Command

```php
// app/Domain/Wallet/Actions/DepositMoney.php
final class DepositMoney
{
    public function handle(string $walletUuid, int $amountInCents, string $currency): void
    {
        WalletAggregate::retrieve($walletUuid)
            ->deposit($amountInCents, $currency)
            ->persist();
    }
}

```

`persist()` writes the stored event to the `stored_events` table and dispatches it to all registered projectors and reactors.

Building a Projector
--------------------

```php
// app/Domain/Wallet/Projectors/WalletBalanceProjector.php
use Spatie\EventSourcing\EventHandlers\Projectors\Projector;

final class WalletBalanceProjector extends Projector
{
    public function onMoneyDeposited(MoneyDeposited $event, string $aggregateUuid): void
    {
        WalletReadModel::updateOrCreate(
            ['uuid' => $aggregateUuid],
            ['balance_in_cents' => DB::raw("balance_in_cents + {$event->amountInCents}")],
        );
    }
}

```

Projectors are **idempotent** and **replayable**. Run `php artisan event-sourcing:replay` to rebuild the entire `wallet_read_models` table from scratch — invaluable when you add a new column or fix a projection bug.

Reactors for Side Effects
-------------------------

```php
// app/Domain/Wallet/Reactors/NotifyOnLargeDeposit.php
use Spatie\EventSourcing\EventHandlers\Reactors\Reactor;

final class NotifyOnLargeDeposit extends Reactor
{
    public function onMoneyDeposited(MoneyDeposited $event, string $aggregateUuid): void
    {
        if ($event->amountInCents >= 100_000) {
            SendLargeDepositAlert::dispatch($aggregateUuid);
        }
    }
}

```

Reactors are **not replayed** — they fire once when the event is first stored. Register both projectors and reactors in a service provider or via auto-discovery in `config/event-sourcing.php`.

Snapshot Support for Long-Lived Aggregates
------------------------------------------

Aggregates with thousands of events become slow to reconstitute. Spatie supports snapshots out of the box:

```php
// In a scheduled command or after every Nth event:
WalletAggregate::retrieve($uuid)->snapshot();

```

On the next `retrieve`, the package loads the snapshot and replays only events recorded after it.

Testing the Aggregate
---------------------

```php
// tests/Unit/WalletAggregateTest.php
use function Spatie\EventSourcing\Tests\AggregateRootTestCase;

it('records a MoneyDeposited event on deposit', function () {
    WalletAggregate::fake()
        ->given([])
        ->when(fn (WalletAggregate $w) => $w->deposit(5000, 'USD'))
        ->assertRecorded(new MoneyDeposited(5000, 'USD'));
});

it('rejects non-positive deposits', function () {
    expect(fn () =>
        WalletAggregate::fake()->when(fn ($w) => $w->deposit(0, 'USD'))
    )->toThrow(InvalidArgumentException::class);
});

```

`AggregateRoot::fake()` keeps events in memory — no database required.

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

- Store *events*, not state; derive read models via projectors.
- Aggregates validate commands and record events — they never query the DB.
- Reactors handle side effects and are skipped during replay.
- Snapshots prevent slow reconstitution for high-volume aggregates.
- `AggregateRoot::fake()` makes unit tests fast and database-free.
- Replay is your safety net: fix a projector bug and rebuild the read model without touching source data.

 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-ceremony&text=Event+Sourcing+in+Laravel%3A+Aggregates%2C+Projectors%2C+and+Reactors+Without+the+Ceremony) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fmsaied.com%2Farticles%2Fevent-sourcing-in-laravel-aggregates-projectors-and-reactors-without-the-ceremony) 

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

  3 questions  

     Q01  When should I choose event sourcing over a standard Eloquent approach?        Event sourcing pays off when you need a full audit trail, the ability to rebuild read models from history, or complex temporal queries. For simple CRUD with no audit requirements, the added complexity is rarely justified. 

      Q02  Does replaying events re-trigger reactors and send duplicate emails?        No. Spatie's package distinguishes projectors (replayed) from reactors (fire-once). Reactors only execute when an event is first stored, not during `event-sourcing:replay` runs. 

      Q03  How do I handle breaking changes to an event's payload over time?        Use event upcasters — classes that transform an old serialized event into the current shape before it reaches your projector or aggregate. Spatie supports upcasters via the `$upcastUsing` property on stored event classes. 

  Continue reading

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

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

 [ ![Job Batching with Laravel Horizon: Reliable Async Workflows at Scale](https://cdn.msaied.com/553/b794b736bfd84f3cbcc6218319916544.png) laravel queues horizon 

### Job Batching with Laravel Horizon: Reliable Async Workflows at Scale

Learn how to combine Laravel's job batching API with Horizon's queue supervision to build fault-tolerant async...

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

 15 Aug 2026     3 min read  

  Read    

 ](https://msaied.com/articles/job-batching-with-laravel-horizon-reliable-async-workflows-at-scale) [ ![Contextual Eloquent Casts: Custom Cast Classes, Value Objects, and Inbound-Only Transforms](https://cdn.msaied.com/552/a7825c0c6f53d934f84fce522573eafb.png) laravel eloquent value-objects 

### Contextual Eloquent Casts: Custom Cast Classes, Value Objects, and Inbound-Only Transforms

Go beyond primitive casts. Learn how to build custom Eloquent cast classes that hydrate value objects, handle...

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

 15 Aug 2026     1 min read  

  Read    

 ](https://msaied.com/articles/contextual-eloquent-casts-custom-cast-classes-value-objects-and-inbound-only-transforms) [ ![Contextual Eloquent Scopes: Binding Query Logic to Domain State at Runtime](https://cdn.msaied.com/551/dc00bc1e6fb2999c99a0b5b8fb42a8c3.png) laravel eloquent architecture 

### Contextual Eloquent Scopes: Binding Query Logic to Domain State at Runtime

Learn how to attach runtime-aware query scopes to Eloquent models using the service container, avoiding scatte...

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

 15 Aug 2026     3 min read  

  Read    

 ](https://msaied.com/articles/contextual-eloquent-scopes-binding-query-logic-to-domain-state-at-runtime) 

   [  ![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)
