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.