Why Roll Your Own Event Sourcing Core?
Packages like spatie/laravel-event-sourcing are excellent starting points, but they introduce conventions that can feel opaque at scale. Understanding the primitives — aggregates, an event store, projectors, and reactors — lets you make deliberate trade-offs instead of inheriting someone else's.
This article builds a minimal, production-ready event sourcing kernel in plain Laravel.
The Event Store: One Table, Append-Only
// database/migrations/xxxx_create_stored_events_table.php
Schema::create('stored_events', function (Blueprint $table) {
$table->id();
$table->uuid('aggregate_uuid')->index();
$table->string('aggregate_version');
$table->string('event_class');
$table->json('payload');
$table->timestamp('recorded_at', 6)->useCurrent();
$table->unique(['aggregate_uuid', 'aggregate_version']);
});
The unique constraint on (aggregate_uuid, aggregate_version) is your optimistic concurrency guard — the database rejects duplicate versions, preventing split-brain writes without a distributed lock.
Aggregate Root: Pure, Stateful, Persistence-Ignorant
abstract class AggregateRoot
{
private array $recordedEvents = [];
protected int $aggregateVersion = 0;
public static function retrieve(string $uuid): static
{
$instance = new static($uuid);
$events = StoredEvent::forAggregate($uuid)->get();
foreach ($events as $stored) {
$instance->apply($stored->toDomainEvent());
$instance->aggregateVersion = $stored->aggregate_version;
}
return $instance;
}
protected function recordThat(DomainEvent $event): void
{
$this->apply($event);
$this->recordedEvents[] = $event;
}
private function apply(DomainEvent $event): void
{
$method = 'apply' . class_basename($event);
if (method_exists($this, $method)) {
$this->$method($event);
}
}
public function persist(): void
{
foreach ($this->recordedEvents as $event) {
$this->aggregateVersion++;
StoredEvent::create([
'aggregate_uuid' => $this->uuid,
'aggregate_version' => $this->aggregateVersion,
'event_class' => get_class($event),
'payload' => $event->toPayload(),
]);
event($event); // dispatch to projectors/reactors
}
$this->recordedEvents = [];
}
}
The aggregate never touches the database directly — retrieve and persist are the only seams. Business logic lives in concrete subclasses.
A Concrete Aggregate
class OrderAggregate extends AggregateRoot
{
public OrderStatus $status = OrderStatus::Pending;
public function place(Money $total, CustomerId $customerId): static
{
$this->recordThat(new OrderPlaced($this->uuid, $total, $customerId));
return $this;
}
public function cancel(string $reason): static
{
if ($this->status === OrderStatus::Shipped) {
throw new \DomainException('Cannot cancel a shipped order.');
}
$this->recordThat(new OrderCancelled($this->uuid, $reason));
return $this;
}
protected function applyOrderPlaced(OrderPlaced $event): void
{
$this->status = OrderStatus::Pending;
}
protected function applyOrderCancelled(OrderCancelled $event): void
{
$this->status = OrderStatus::Cancelled;
}
}
Guard clauses live in command methods. apply* methods are side-effect-free state transitions — never throw from them.
Projectors: Building Read Models
Projectors are standard Laravel listeners. Register them in EventServiceProvider:
class OrderProjector
{
public function onOrderPlaced(OrderPlaced $event): void
{
OrderReadModel::create([
'uuid' => $event->aggregateUuid,
'customer_id' => $event->customerId->value(),
'total_cents' => $event->total->cents(),
'status' => 'pending',
]);
}
public function onOrderCancelled(OrderCancelled $event): void
{
OrderReadModel::where('uuid', $event->aggregateUuid)
->update(['status' => 'cancelled']);
}
}
For replaying projections, iterate stored_events in chunks and re-dispatch events synchronously — no queue involved:
StoredEvent::query()
->where('event_class', OrderPlaced::class)
->chunkById(500, function ($chunk) use ($projector) {
foreach ($chunk as $stored) {
$projector->onOrderPlaced($stored->toDomainEvent());
}
});
Reactors: Async Side Effects
Reactors trigger external work — emails, webhooks, third-party APIs. They should always run on a queue:
class NotifyCustomerOnCancellation implements ShouldQueue
{
public function handle(OrderCancelled $event): void
{
Mail::to($event->customerEmail)->send(new OrderCancelledMail($event));
}
}
Because reactors are queued, a transient failure doesn't roll back your event store. Design them to be idempotent — check before acting, or use a processed-events log.
Key Takeaways
- Optimistic concurrency via a unique DB constraint is simpler than distributed locks and sufficient for most workloads.
- Aggregates are pure — no Eloquent, no HTTP, no side effects inside
apply*methods. - Projectors replay deterministically by iterating the event store; keep them side-effect-free.
- Reactors are queued and must be idempotent — the event store is the source of truth, not the side effect.
- You can introduce event sourcing incrementally on a single aggregate without rewriting the whole application.