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.