Why Roll Lean Instead of Reaching for a Package
Spatie's laravel-event-sourcing is excellent, but it carries opinions about aggregate roots, stored events, and projectors that can feel heavy for teams who only need parts of the pattern. Understanding the mechanics first — then choosing a library — leads to better decisions.
This article builds a minimal but production-honest event sourcing kernel: an append-only event store, a replayable projector, aggregate snapshots, and a safe replay strategy.
The Event Store
The foundation is a single append-only table. Never update or delete rows.
Schema::create('domain_events', function (Blueprint $table) {
$table->id();
$table->uuid('aggregate_id')->index();
$table->string('aggregate_type');
$table->unsignedInteger('version');
$table->string('event_type');
$table->jsonb('payload');
$table->timestamp('occurred_at', 6)->useCurrent();
$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 throw a QueryException — catch it and surface a domain conflict.
final class EventStore
{
public function append(string $aggregateId, string $type, DomainEvent $event, int $expectedVersion): void
{
DB::table('domain_events')->insert([
'aggregate_id' => $aggregateId,
'aggregate_type' => $type,
'version' => $expectedVersion + 1,
'event_type' => $event::class,
'payload' => json_encode($event->toArray()),
'occurred_at' => now(),
]);
}
public function loadFrom(string $aggregateId, int $fromVersion = 0): Collection
{
return DB::table('domain_events')
->where('aggregate_id', $aggregateId)
->where('version', '>', $fromVersion)
->orderBy('version')
->get();
}
}
Aggregates Without Magic
An aggregate records events internally and applies them to mutate state.
abstract class AggregateRoot
{
private array $recordedEvents = [];
protected int $version = 0;
protected function recordThat(DomainEvent $event): void
{
$this->apply($event);
$this->recordedEvents[] = $event;
$this->version++;
}
abstract protected function apply(DomainEvent $event): void;
public function releaseEvents(): array
{
$events = $this->recordedEvents;
$this->recordedEvents = [];
return $events;
}
public function version(): int { return $this->version; }
}
Reconstitution replays stored events through apply() without re-recording them:
public static function reconstitute(Collection $storedEvents): static
{
$aggregate = new static();
foreach ($storedEvents as $row) {
$eventClass = $row->event_type;
$aggregate->apply($eventClass::fromArray(json_decode($row->payload, true)));
$aggregate->version = $row->version;
}
return $aggregate;
}
Snapshots: Skip the Full Replay
For aggregates with thousands of events, replaying from zero is expensive. Snapshots cache state at a version checkpoint.
Schema::create('aggregate_snapshots', function (Blueprint $table) {
$table->uuid('aggregate_id')->primary();
$table->unsignedInteger('version');
$table->jsonb('state');
$table->timestamp('taken_at')->useCurrent();
});
Load the snapshot first, then only replay events after its version:
public function load(string $aggregateId): MyAggregate
{
$snapshot = DB::table('aggregate_snapshots')
->where('aggregate_id', $aggregateId)
->first();
$fromVersion = $snapshot?->version ?? 0;
$events = $this->store->loadFrom($aggregateId, $fromVersion);
if ($snapshot) {
$aggregate = MyAggregate::fromSnapshot(json_decode($snapshot->state, true));
} else {
$aggregate = MyAggregate::reconstitute($events);
return $aggregate;
}
return MyAggregate::reconstituteFrom($aggregate, $events);
}
Snapshot every N events (e.g., 50) inside your command handler after persisting.
Projectors and Safe Replay
A projector listens to stored events and builds a read model. Keep projectors idempotent — replay must be safe to run multiple times.
final class OrderSummaryProjector
{
public function onOrderPlaced(OrderPlaced $event): void
{
DB::table('order_summaries')->upsert(
['order_id' => $event->orderId, 'status' => 'placed', 'total' => $event->total],
['order_id'],
['status', 'total']
);
}
}
For replay, truncate the read model table first, then stream events in chunks:
DB::table('order_summaries')->truncate();
DB::table('domain_events')
->where('event_type', OrderPlaced::class)
->orderBy('id')
->chunk(500, function ($rows) use ($projector) {
foreach ($rows as $row) {
$projector->onOrderPlaced(
OrderPlaced::fromArray(json_decode($row->payload, true))
);
}
});
Wrap replay in a queue job with a unique lock so two replays never race.
Takeaways
- The
unique(aggregate_id, version)constraint gives you optimistic concurrency for free at the DB level. - Snapshots are a performance concern, not a correctness concern — add them only when replay latency becomes measurable.
- Projectors must be idempotent;
upsert()is your friend. - Replay is a maintenance operation — run it in a queued job with a mutex, never in a request cycle.
- You can adopt this pattern incrementally: start with one aggregate and one projector before committing to a full framework.