Skip to content

Event Sourcing in Laravel: Aggregates, Projectors, and Rebuilding Read Models

A practical walkthrough of event sourcing in Laravel — defining aggregates, persisting domain events, building projectors, and safely rebuilding read models without downtime.

Published · Updated · 3 min read

Event Sourcing in Laravel: Aggregates, Projectors, and Rebuilding Read Models

Why Event Sourcing Is Worth the Complexity

Event sourcing replaces mutable row updates with an append-only log of domain events. Your database never forgets what happened — it only records that it happened. The current state is a projection of that history. For audit-heavy, financially sensitive, or highly collaborative domains, this is not over-engineering; it is the correct model.

This article focuses on the mechanics: aggregates, the event store, projectors, and the rebuild story.


Defining a Domain Event

Domain events are plain, immutable value objects. Use PHP 8.2+ readonly classes:

final readonly class MoneyDeposited
{
    public function __construct(
        public string $accountId,
        public int    $amountCents,
        public string $currency,
        public \DateTimeImmutable $occurredAt,
    ) {}
}

No Eloquent, no infrastructure concerns — just data.


The Aggregate Root

An aggregate root applies events to itself and records them for persistence. It never touches the database directly.

final class BankAccount
{
    private int $balanceCents = 0;
    private array $recordedEvents = [];

    public static function open(string $id, int $initialDeposit): self
    {
        $account = new self($id);
        $account->apply(new MoneyDeposited($id, $initialDeposit, 'USD', new \DateTimeImmutable()));
        return $account;
    }

    public function deposit(int $amountCents): void
    {
        if ($amountCents <= 0) {
            throw new \DomainException('Deposit must be positive.');
        }
        $this->apply(new MoneyDeposited($this->id, $amountCents, 'USD', new \DateTimeImmutable()));
    }

    private function apply(object $event): void
    {
        $this->recordedEvents[] = $event;
        $this->when($event);
    }

    private function when(object $event): void
    {
        match (true) {
            $event instanceof MoneyDeposited => $this->balanceCents += $event->amountCents,
            default => null,
        };
    }

    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];
        return $events;
    }
}

The when() method is the state machine. It is also used during reconstitution — replaying stored events rebuilds the aggregate without hitting any read model.


The Event Store

Store events as serialised JSON rows, never update them:

Schema::create('stored_events', function (Blueprint $table) {
    $table->id();
    $table->string('aggregate_id')->index();
    $table->string('aggregate_type');
    $table->string('event_class');
    $table->jsonb('payload');
    $table->unsignedBigInteger('version');
    $table->timestamp('created_at');
    $table->unique(['aggregate_id', 'version']); // optimistic concurrency
});

The unique constraint on (aggregate_id, version) is your optimistic concurrency guard — two concurrent writes for the same version will produce a database error, not silent data corruption.


Projectors: Building Read Models

A projector listens to stored events and maintains a denormalised read model:

final class AccountBalanceProjector
{
    public function onMoneyDeposited(MoneyDeposited $event): void
    {
        AccountBalance::updateOrCreate(
            ['account_id' => $event->accountId],
            ['balance_cents' => \DB::raw("balance_cents + {$event->amountCents}")],
        );
    }
}

Projectors are side-effect machines. Keep them thin — no business logic, no conditionals beyond routing.


Rebuilding Read Models Safely

This is the killer feature. When your read model schema changes, replay all events:

final class RebuildAccountBalances extends Command
{
    protected $signature = 'projections:rebuild-balances';

    public function handle(EventStore $store, AccountBalanceProjector $projector): void
    {
        AccountBalance::truncate();

        $store->allForType(BankAccount::class)
            ->lazy()
            ->each(function (StoredEvent $stored) use ($projector) {
                $event = $stored->toEvent();
                if ($event instanceof MoneyDeposited) {
                    $projector->onMoneyDeposited($event);
                }
            });

        $this->info('Rebuild complete.');
    }
}

For zero-downtime rebuilds, write to a shadow table, verify, then swap with a transaction and a view rename.


Packages Worth Knowing

spatie/laravel-event-sourcing provides a solid, production-tested foundation — aggregate roots, projectors, reactors, and a stored events table out of the box. Roll your own only if you have constraints it cannot satisfy.


Key Takeaways

  • Aggregates own business rules; they never touch persistence directly.
  • Events are immutable facts — readonly classes enforce this at the language level.
  • Optimistic concurrency via a unique (aggregate_id, version) index prevents split-brain writes.
  • Projectors are disposable — the event log is the truth; read models are derived and rebuildable.
  • Lazy collection replay keeps memory flat during large rebuilds.
  • Shadow-table rebuilds enable schema migrations without downtime.

Frequently asked questions

Do I need event sourcing for every Laravel application?

No. Event sourcing adds real complexity — serialisation, replay logic, projector maintenance. It pays off in domains where audit history, temporal queries, or complex state machines are core requirements. For a standard CRUD app, it is overkill.

How do I handle aggregate reconstitution from the event store?

Load all stored events for a given aggregate ID ordered by version, then replay them through the aggregate's `when()` method without calling `apply()` (which would re-record them). The final in-memory state is the current aggregate state.

What happens if a projector fails mid-rebuild?

Track the last successfully processed event ID in a checkpoint table. On retry, resume from that checkpoint rather than replaying from the beginning. This makes rebuilds idempotent and safe to interrupt.

Mohamed Said

About the author

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 GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project