Event Sourcing in Laravel: Aggregates, Projectors, and Reactors Without the Ceremony
#laravel #event-sourcing #ddd #cqrs #spatie

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

4 min read Mohamed Said Mohamed Said

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

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

// 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 <= 0) {
            throw new InvalidArgumentException('Deposit must be positive.');
        }

        $this->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

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

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

// 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:

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

// 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?

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