Why Event Sourcing Is Not Just a Fancy Audit Log
Most Laravel applications store current state. Event sourcing flips that: you store what happened, and derive state from the event stream. The audit log is a side-effect, not the goal. The goal is a system where every state transition is an explicit, replayable fact.
This article focuses on the mechanics — aggregates, domain events, projectors, and reactors — using spatie/laravel-event-sourcing as the runtime, but the concepts apply regardless of library.
The Aggregate Root
An aggregate root is the consistency boundary. All mutations go through it; it raises events instead of writing directly to a database.
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;
final class OrderAggregate extends AggregateRoot
{
private OrderStatus $status = OrderStatus::Pending;
private int $totalCents = 0;
public function place(array $items, int $totalCents): static
{
$this->recordThat(new OrderPlaced($items, $totalCents));
return $this;
}
public function cancel(string $reason): static
{
if ($this->status !== OrderStatus::Pending) {
throw new \DomainException('Only pending orders can be cancelled.');
}
$this->recordThat(new OrderCancelled($reason));
return $this;
}
protected function applyOrderPlaced(OrderPlaced $event): void
{
$this->status = OrderStatus::Pending;
$this->totalCents = $event->totalCents;
}
protected function applyOrderCancelled(OrderCancelled $event): void
{
$this->status = OrderStatus::Cancelled;
}
}
The apply* methods rebuild state from the event stream. No Eloquent, no DB calls inside the aggregate.
Raising Events from a Command Handler
final class PlaceOrderHandler
{
public function handle(PlaceOrderCommand $command): void
{
OrderAggregate::retrieve($command->orderId)
->place($command->items, $command->totalCents)
->persist();
}
}
persist() writes the new events to the stored_events table and dispatches them to all registered projectors and reactors.
Projectors: Building Read Models
A projector listens to events and maintains a denormalized read model — the "Q" side of CQRS.
use Spatie\EventSourcing\EventHandlers\Projectors\Projector;
final class OrderSummaryProjector extends Projector
{
public function onOrderPlaced(OrderPlaced $event, string $aggregateUuid): void
{
DB::table('order_summaries')->insert([
'uuid' => $aggregateUuid,
'status' => 'pending',
'total_cents' => $event->totalCents,
'created_at' => now(),
]);
}
public function onOrderCancelled(OrderCancelled $event, string $aggregateUuid): void
{
DB::table('order_summaries')
->where('uuid', $aggregateUuid)
->update(['status' => 'cancelled']);
}
}
Because projectors replay from the event log, you can drop the order_summaries table and rebuild it at any time — a superpower when requirements change.
Reactors: Side-Effects Without Coupling
Reactors handle side-effects (emails, webhooks, third-party calls). Unlike projectors, they are not replayed.
use Spatie\EventSourcing\EventHandlers\Reactors\Reactor;
final class NotifyCustomerOnCancellation extends Reactor
{
public function onOrderCancelled(OrderCancelled $event, string $aggregateUuid): void
{
$order = OrderSummary::where('uuid', $aggregateUuid)->firstOrFail();
Mail::to($order->customer_email)->send(new OrderCancelledMail($order));
}
}
Snapshots for Long-Lived Aggregates
Replaying thousands of events on every command is expensive. Snapshots cache aggregate state at a point in time:
OrderAggregate::retrieve($uuid)
->snapshot(); // persists a snapshot; future retrieval starts from here
The library handles merging the snapshot with subsequent events automatically.
Honest Trade-offs
Event sourcing is not free:
- Eventual consistency means your read models may lag behind writes. Design your UI accordingly.
- Schema evolution is hard. Stored events are immutable; changing an event's shape requires upcasters.
- Debugging requires tooling to inspect the event stream;
tinkeralone won't cut it. - Team onboarding cost is real — the pattern is unfamiliar to most Laravel developers.
Reserve it for domains where auditability, temporal queries, or event-driven integrations justify the overhead. A CRUD admin panel does not.
Key Takeaways
- Aggregates enforce invariants and raise events; they never touch the database directly.
- Projectors build read models and are fully replayable from the event log.
- Reactors handle side-effects and run once — never on replay.
- Snapshots prevent performance degradation on aggregates with long event histories.
- Eventual consistency is a first-class concern, not an implementation detail to hide.