Event Sourcing in Production Laravel: Snapshots, Schema Evolution, and Read-Model Rebuilds
Event sourcing looks straightforward in the tutorials. You write an aggregate, fire events, project them into a read model, take a snapshot every hundred events, and you have an audit-friendly, time-travel-able system. The tutorial stops there. The second year of production is where the interesting problems start: the snapshot was taken with a field that no longer exists, a read model needs to be rebuilt but the events are now in v3 format, the projection has been quietly out of sync for six weeks because a new event type was added without wiring it up. None of these problems have a clean answer in the tutorials, because the tutorials describe the happy path. This article is the rest of the path.
I am going to assume you have shipped event sourcing in production and felt at least one of those pain points. If you are still deciding whether to adopt it, read the section When event sourcing is wrong first — most systems should not adopt it, and adopting it is expensive to undo. If you have shipped it, this is the operating manual for the second year.
Everything below uses plain Laravel — DB, the queue dispatcher, the event system, the artisan console. No third-party event sourcing package. The reason is that the package boundaries do not match the production concerns, and the production concerns are what bite you in year two. We will build the minimum skeleton first and then look at where the coupling breaks.
The skeleton is a working Laravel 13 project at github.com/michalwolinski/laravel-event-sourcing-snapshots-schema-evolution-rebuilds. Every code snippet in this article is a real file in that repo, on PHP 8.2+, strict types, Pint-clean, with sixteen tests covering every claim below.
The three concerns are coupled
The reason these problems are hard is that the three concerns are not independent. Snapshots capture aggregate state at version N. Schema evolution changes the meaning of stored events at version M. Read-model rebuilds replay stored events from version K through the present. A decision in one layer constrains what the others can do.
A snapshot taken at version 100, when stored events have since been migrated from v1 to v2 shape, cannot be replayed forward over newer events without re-applying the shape transformation. A projection rebuilt from the event store will silently disagree with the snapshots if the projection logic and the aggregate's apply logic have diverged. A schema evolution that adds a required field will fail to load every snapshot taken before the field existed, even if the aggregate's behaviour since has always populated it.
The mistake most teams make is treating snapshots, schema evolution, and rebuilds as separate problems with separate solutions. They are not. They are one problem expressed in three places, and a fix in one place has to be paired with a fix in the others or you trade one failure mode for another. The rest of this article is the shape of that coupling, and the practices that keep it from breaking.
The minimum skeleton
Before we talk about failure modes, we need a concrete reference implementation. The minimum Laravel skeleton for event sourcing is the two log tables (stored_events, aggregate_snapshots), a DomainEvent interface with a trait behind it, a Projection and a Reactor interface, a serializer registry, a handler registry plus the dispatch seam, and four artisan commands. Both log tables are immutable once written. Each projection owns its own read-model table; the demo ships a shipment_read_model and an order_summary_read_model, and your application replaces them.
The two log tables, one migration file each:
<?php // database/migrations/..._create_stored_events_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('stored_events', function (Blueprint $table) {
$table->id();
$table->uuid('aggregate_uuid')->nullable();
$table->unsignedBigInteger('aggregate_version')->nullable();
$table->unsignedTinyInteger('event_version')->default(1);
$table->string('event_class');
$table->json('event_properties');
$table->json('metadata')->nullable();
$table->timestamp('created_at')->useCurrent();
// Optimistic concurrency: the same aggregate cannot produce
// two events at the same version, ever. An insert that
// violates this constraint signals a concurrent command and
// the runtime aborts the command.
$table->unique(['aggregate_uuid', 'aggregate_version']);
$table->index('event_class');
$table->index('aggregate_uuid');
});
}
public function down(): void
{
Schema::dropIfExists('stored_events');
}
};
<?php // database/migrations/..._create_aggregate_snapshots_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('aggregate_snapshots', function (Blueprint $table) {
$table->id();
$table->uuid('aggregate_uuid');
$table->unsignedBigInteger('aggregate_version');
$table->json('state');
$table->timestamp('created_at')->useCurrent();
$table->unique('aggregate_uuid');
});
}
public function down(): void
{
Schema::dropIfExists('aggregate_snapshots');
}
};
The stored_events table has updated_at deliberately omitted. Events are immutable; a row's updated_at would be a lie. The aggregate_snapshots table also has no updated_at for the same reason — a snapshot row is the state at a version, not a mutable record.
The DomainEvent interface every stored event implements, with the trait that provides it. The runtime metadata (aggregate identity, schema version, recorded-at timestamp) is set only through the with* methods, only by the runtime, and kept in private properties so the JSON serializer's get_object_vars() sees the event's own payload and nothing else:
<?php // app/Domain/Events/DomainEvent.php
declare(strict_types=1);
namespace App\Domain\Events;
use DateTimeImmutable;
interface DomainEvent
{
public function aggregateUuid(): string;
public function aggregateVersion(): int;
public function eventVersion(): int;
public function recordedAt(): ?DateTimeImmutable;
public function withAggregateIdentity(string $uuid, int $version): static;
public function withRecordedAt(DateTimeImmutable $recordedAt): static;
public function withEventVersion(int $version): static;
}
<?php // app/Domain/Concerns/StoresEvents.php
declare(strict_types=1);
namespace App\Domain\Concerns;
use DateTimeImmutable;
trait StoresEvents
{
private string $aggregateUuid = '';
private int $aggregateVersion = 0;
private int $eventVersion = 1;
private ?DateTimeImmutable $recordedAt = null;
public function aggregateUuid(): string
{
return $this->aggregateUuid;
}
public function aggregateVersion(): int
{
return $this->aggregateVersion;
}
public function eventVersion(): int
{
return $this->eventVersion;
}
public function recordedAt(): ?DateTimeImmutable
{
return $this->recordedAt;
}
public function withAggregateIdentity(string $uuid, int $version): static
{
$this->aggregateUuid = $uuid;
$this->aggregateVersion = $version;
return $this;
}
public function withRecordedAt(DateTimeImmutable $recordedAt): static
{
$this->recordedAt = $recordedAt;
return $this;
}
public function withEventVersion(int $version): static
{
$this->eventVersion = $version;
return $this;
}
}
The runtime, not the domain code, calls withAggregateIdentity and withRecordedAt on the event before the row hits stored_events. The accessors are how the metadata flows from the aggregate to the persistence layer without the domain event having to carry a persistence-shaped contract.
The two handler interfaces. A Projection is replay-safe and idempotent; a Reactor fires side effects and is never called during replay — that split is the whole reason they are separate interfaces:
<?php // app/Domain/Projections/Projection.php
declare(strict_types=1);
namespace App\Domain\Projections;
use App\Domain\Events\DomainEvent;
interface Projection
{
/** @param class-string $eventClass */
public function handles(string $eventClass): bool;
public function handle(DomainEvent $event): void;
/** @return list<string> */
public function tables(): array;
}
<?php // app/Domain/Reactors/Reactor.php
declare(strict_types=1);
namespace App\Domain\Reactors;
use App\Domain\Events\DomainEvent;
interface Reactor
{
/** @param class-string $eventClass */
public function handles(string $eventClass): bool;
public function handle(DomainEvent $event): void;
}
The four artisan commands: domain:replay {projection} (rebuild a single projection from the event log), domain:snapshot {aggregate} {uuid} (force a snapshot of a specific aggregate), domain:invalidate-snapshots --force (truncate the snapshot table after a serializer version ships), and domain:events {aggregate_uuid?} (debug inspection of the log).
The wiring — which serializer handles which event, which projections are registered, the listener that turns a persisted event into a dispatch — lives in one EventSourcingServiceProvider. Registration is manual on purpose: the article's argument is that knowing where every handler is wired is the discipline that keeps the system maintainable past year two. The rest of the article refers back to this skeleton. The reasoning is the same whether you use it, a third-party package, or your own implementation; what changes is which failure modes you have to defend against yourself.
Snapshots: when and how often
Snapshots exist to bound rehydration cost. Without a snapshot, every retrieve($uuid) reads every event for that aggregate since the aggregate was created, in order, and applies each one. For an aggregate with five events that is irrelevant. For an aggregate with fifty thousand events, it is the difference between a 5 ms command and a 2 s command — and a 2 s command is the difference between a working checkout and a timeout the customer sees.
The classical rule of thumb is “every 100 events per aggregate root”. That number survives because the math behind it survives: an aggregate that fires one event per user action accumulates 100 events after roughly 100 user actions, and most aggregates in a real domain do not see more than a few hundred user actions over their lifetime. Where the rule breaks is aggregates that fire more than one event per action — order placement with line-item reservations, ledger entries that emit both a debit and a credit, audit trails with companion events. Those aggregates accumulate events faster, and the 100-event threshold comes much sooner.
Before the cadence question, a word on what persist() already guards. Every recordThat() bumps the aggregate's version counter; persist() first fast-fails if the log has already moved past the version the aggregate loaded at, then wraps the insert in a transaction and catches UniqueConstraintViolationException on the (aggregate_uuid, aggregate_version) index. Either path throws a domain ConcurrencyException — "command expected version N, but the log is already at version M. Reload and retry." The fast-fail turns the common case into a cheap read; the unique index is the guard that actually holds under a true race, when both commands pass the fast-fail and only one insert can win.
The right answer to how often to snapshot is not a fixed number. It is a per-aggregate cadence derived from the cost of rehydration against the cost of taking the snapshot. Three inputs:
- Average events per aggregate over its lifetime. Measure it in production, not on day one. Pick the 95th percentile aggregate, not the median, because the long tail is what hurts.
- Time to replay all events vs time to load the snapshot plus replay since the snapshot. If replay is 10 ms and the snapshot saves 5 ms, you do not need snapshots. If replay is 2 s and the snapshot saves 1.9 s, you do.
- Storage cost of the snapshot row. A snapshot row per aggregate is cheap until you have a million aggregates and you discover your snapshot table is bigger than your event table. The schema above stores
aggregate_uuid,aggregate_version, and ajsonstate column. For an aggregate with twenty public properties, that is kilobytes per row. At a million aggregates, that is one gigabyte — fine. At a hundred million, that is a hundred gigabytes and an index maintenance problem you do not want.
The implementation question is where the snapshot call lives. Two options:
- Manually, in the command handler or after a state-changing command. The skeleton ships with a manual
$aggregate->snapshot()call on the aggregate. The cost is that the snapshot is not atomically consistent with the events that produced it: the events may be persisted while the snapshot is still being computed, and a concurrentretrievemay see events without their corresponding snapshot state. - Inside the same database transaction. Wrap the event persist and the snapshot write in
DB::transaction(function () { ... }). Snapshot is consistent, but the transaction holds its locks for the duration of the snapshot write, which serializes every Nth command on that aggregate against the snapshot workload. The throughput cost is real.
The right choice depends on what the inconsistency costs you. If a retrieve sees events but no snapshot and replays from event zero, the cost is one slower rehydration per missed snapshot — acceptable, because the next snapshot will close the gap. If the cost is higher (financial reconciliation, for instance), wrap in a transaction. Default to outside-transaction, raise the stakes before tightening.
The pattern that survives in production is “snapshot every N events where N is the smallest number such that the time to replay N events is below your command latency budget”. If a command must respond in 100 ms and replaying one event takes 2 ms, N is around 50. If events are batched per command and a command fires ten events at once, N around five. Measure, do not guess.
Schema evolution: the part the docs skip
Events are immutable facts. The shape of an event at the time it was written is the shape it will always have, even when the rest of the world has moved on. That is the principle, and it is true at the storage layer. It is not true at the consumer layer: every aggregate, every projection, every reactor that loads the event later sees it through whatever shape they expect. If the shape has changed, the consumer sees a v1 event with v3 eyes, and that is where bugs are born.
There are three patterns. The first is additive changes: a new optional field with a default that satisfies the absence. This is the only schema change you can make without any version machinery. If you can keep changes additive forever, do that. Most teams cannot.
The second is renames and splits: a field gets renamed, a value gets split into two. This is where the version number on the event becomes load-bearing. The implementation is a custom serializer registered per event class in a registry, branching on $version:
<?php // app/Domain/Serializers/EventSerializer.php
declare(strict_types=1);
namespace App\Domain\Serializers;
use App\Domain\Events\DomainEvent;
interface EventSerializer
{
public function serialize(DomainEvent $event): string;
/** @param class-string<DomainEvent> $eventClass */
public function deserialize(
string $eventClass,
string $json,
int $version,
?string $metadata = null,
): DomainEvent;
}
The third argument is the event_version column, passed on every read. The default serializer encodes the event's public properties — which are exactly its domain payload, since the runtime metadata is private on the trait — and on read rebuilds the event and re-attaches the metadata from the metadata column through a hydrate() helper the version-aware subclasses reuse:
<?php // app/Domain/Serializers/JsonEventSerializer.php
declare(strict_types=1);
namespace App\Domain\Serializers;
use App\Domain\Events\DomainEvent;
use DateTimeImmutable;
class JsonEventSerializer implements EventSerializer
{
public function serialize(DomainEvent $event): string
{
return json_encode(get_object_vars($event), JSON_THROW_ON_ERROR);
}
public function deserialize(
string $eventClass,
string $json,
int $version,
?string $metadata = null,
): DomainEvent {
/** @var array<string, mixed> $properties */
$properties = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
return $this->hydrate($eventClass, $properties, $metadata);
}
/**
* @param class-string<DomainEvent> $eventClass
* @param array<string, mixed> $properties
*/
protected function hydrate(string $eventClass, array $properties, ?string $metadata): DomainEvent
{
$event = new $eventClass(...$properties);
if (null === $metadata) {
return $event;
}
/** @var array{aggregate_uuid?: string, aggregate_version?: int|string, recorded_at?: string} $meta */
$meta = json_decode($metadata, true, 512, JSON_THROW_ON_ERROR);
$event->withAggregateIdentity(
$meta['aggregate_uuid'] ?? '',
(int) ($meta['aggregate_version'] ?? 0),
);
if (isset($meta['recorded_at'])) {
$event->withRecordedAt(new DateTimeImmutable($meta['recorded_at']));
}
return $event;
}
}
A version-aware serializer subclasses JsonEventSerializer, overrides deserialize(), and branches on $version before delegating to hydrate(). The skeleton ships with one: OrderPlacedSerializer, which converts the v1 comma-separated SKU string into the v2 typed-array shape on read:
<?php // app/Domain/Demo/Serializers/OrderPlacedSerializer.php
declare(strict_types=1);
namespace App\Domain\Demo\Serializers;
use App\Domain\Demo\Events\OrderPlaced;
use App\Domain\Events\DomainEvent;
use App\Domain\Serializers\JsonEventSerializer;
final class OrderPlacedSerializer extends JsonEventSerializer
{
public function deserialize(
string $eventClass,
string $json,
int $version,
?string $metadata = null,
): DomainEvent {
/** @var array{orderId: string, customerId: string, items: string|list<array{sku: string, price: int}>} $properties */
$properties = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
// v1 stored items as a comma-separated sku string. The
// is_string() check is defensive: a backfill might have
// written a v2 array while leaving event_version at 1.
if (1 === $version && is_string($properties['items'])) {
$properties['items'] = $this->explodeItems($properties['items']);
}
return $this->hydrate(OrderPlaced::class, $properties, $metadata);
}
/** @return list<array{sku: string, price: int}> */
private function explodeItems(string $csv): array
{
return array_map(
fn (string $sku): array => ['sku' => $sku, 'price' => $this->priceFor($sku)],
explode(',', $csv),
);
}
// Deterministic pseudo-price so the demo stays self-contained.
// In production this would be a lookup or a pricing-service
// call — and doing that here turns every replay into a
// round-trip per event, which is the landmine to avoid.
private function priceFor(string $sku): int
{
return (int) (crc32($sku) % 10_000);
}
}
The serializer is the only place that touches the old shape. Every consumer downstream of it — the aggregate and every projection — sees only the current shape. The $version parameter is the load-bearing piece: it is what the serializer uses to decide which shape to produce.
Serializers are registered per event class in a registry:
<?php // app/Domain/Serializers/SerializerRegistry.php
declare(strict_types=1);
namespace App\Domain\Serializers;
use App\Domain\Events\DomainEvent;
final class SerializerRegistry
{
/** @var array<class-string<DomainEvent>, EventSerializer> */
private array $serializers = [];
public function __construct(
private readonly EventSerializer $default,
) {}
/** @param class-string<DomainEvent> $eventClass */
public function register(string $eventClass, EventSerializer $serializer): void
{
$this->serializers[$eventClass] = $serializer;
}
/** @param class-string<DomainEvent> $eventClass */
public function for(string $eventClass): EventSerializer
{
return $this->serializers[$eventClass] ?? $this->default;
}
}
The runtime asks the registry for the right serializer per event. OrderPlacedSerializer handles only OrderPlaced; every other event falls back to the default. This per-class mapping is the load-bearing piece — registering the version-aware serializer globally would send OrderShipped through OrderPlacedSerializer, whose hydrate(OrderPlaced::class, ...) call would then fail on the missing items constructor argument.
The third pattern is the one teams skip and regret: breaking changes that cannot be derived. A field's type changes from string to structured object, a required field is added, an event class is split into two. These cannot be modeled as a function from old payload to new payload, because the new shape needs information that does not exist in the old event. There are only two honest answers: a backfill migration that derives the new field from external systems (slow, but correct), or accepting that the system carries both shapes indefinitely and the serializer dispatches on version. The shape you cannot use is “rewrite history” — the events are immutable, and any process that pretends otherwise is eventually going to lose data.
Here is the part that goes wrong in production. The serializer runs at read time, but the snapshot stored for the aggregate was written at write time. If the snapshot was taken under v1 shape and the serializer transforms v1 events to v2, then replaying events after the snapshot involves events that have already been deserialized as v2 — but the aggregate state in the snapshot was the v1 state, never reserialized. The aggregate's apply logic receives v2 events on top of a v1-state snapshot. The two views of the world disagree.
The fix is to invalidate every snapshot when a serializer version ships. The skeleton has no automatic way to do this — the snapshot table does not know which serializer versions are deployed — so the operational step is on you. The skeleton ships with php artisan domain:invalidate-snapshots --force for exactly this purpose. It truncates aggregate_snapshots, forcing every aggregate to reconstitute from the event log under the new serializer. Rehydration is expensive, which is the cost of changing schema. Plan for it.
Read-model rebuilds: the hidden requirement
A read model — in our skeleton, a class implementing the Projection interface — is a stateful function over the event log. The log is append-only and the state is disposable. This is the property that makes event sourcing different from a CRUD app: you can throw away the read model and rebuild it from scratch. If your projection logic changes, you can rebuild. If you discover a bug in production that has been running for three months, you can rebuild. If you want to add a new column that was always implicit in the events, you can rebuild. The rebuild is the safety net that justifies the cost of event sourcing in the first place.
Most teams never exercise this safety net because the rebuild is hard to do safely. They write the projection, ship it, and treat it as append-only. Three years in, the read model is out of sync with the event store by a delta no one can measure, and the rebuild safety net has rusted shut.
The projection interface requires three methods: handles() to declare which events it cares about, handle() for the logic, and tables() for the rebuild command to truncate. The skeleton ships two demo projections. ShipmentProjection is driven by OrderShipped:
<?php // app/Domain/Demo/Projections/ShipmentProjection.php
declare(strict_types=1);
namespace App\Domain\Demo\Projections;
use App\Domain\Demo\Events\OrderShipped;
use App\Domain\Events\DomainEvent;
use App\Domain\Projections\Projection;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
final class ShipmentProjection implements Projection
{
private const TABLE = 'shipment_read_model';
public function handles(string $eventClass): bool
{
return OrderShipped::class === $eventClass;
}
public function handle(DomainEvent $event): void
{
if (false === $event instanceof OrderShipped) {
return;
}
// Idempotency key: aggregate uuid + aggregate version.
// Unique across the whole log, so a replay re-processing
// the same event is a no-op, not a duplicate row.
$idempotencyKey = $event->aggregateUuid().':'.$event->aggregateVersion();
$alreadyProjected = DB::table(self::TABLE)
->where('idempotency_key', $idempotencyKey)
->exists();
if ($alreadyProjected) {
return;
}
$shippedAt = $event->recordedAt() ?? CarbonImmutable::now();
DB::table(self::TABLE)->insert([
'idempotency_key' => $idempotencyKey,
'order_id' => $event->orderId,
'tracking_number' => $event->trackingNumber,
'shipped_at' => $shippedAt,
'created_at' => $shippedAt,
'updated_at' => $shippedAt,
]);
}
public function tables(): array
{
return [self::TABLE];
}
}
The second demo projection, OrderSummaryProjection, is the one that makes the coupling concrete. It is driven by OrderPlaced and reads $event->items as the v2 typed array to compute a line count and a total. A v1 row stored items as a string; if the rebuild handed the projection the raw payload, this handler would break on the first v1 row. It works only because the rebuild deserializes every row through OrderPlacedSerializer first. That is the whole argument of this article, in one read model.
The idempotency key — aggregateUuid:aggregateVersion — is the canonical answer to "how do I make a projection idempotent": it is the one piece of state guaranteed unique across replays. The domain key (order id, tracking number) is the right index for lookups but the wrong idempotency key, because the same order id can recur across events.
The artisan command that rebuilds it, with the version-aware replay path:
<?php // app/Domain/Console/ReplayProjection.php
declare(strict_types=1);
namespace App\Domain\Console;
use App\Domain\Projections\Projection;
use App\Domain\Serializers\SerializerRegistry;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class ReplayProjection extends Command
{
private const CHUNK = 500;
protected $signature = 'domain:replay {projection : Fully-qualified projection class}';
protected $description = 'Rebuild a projection from the event log';
public function handle(SerializerRegistry $serializers): int
{
$projection = $this->resolveProjection($this->argument('projection'));
if (null === $projection) {
return self::FAILURE;
}
// Truncate before any event is read, so the projection is
// clean even if the replay is interrupted.
foreach ($projection->tables() as $table) {
DB::table($table)->truncate();
$this->info("Truncated {$table}");
}
$replayed = 0;
DB::table('stored_events')
->orderBy('id')
->chunkById(self::CHUNK, function (iterable $rows) use ($projection, $serializers, &$replayed): void {
foreach ($rows as $row) {
if (false === $projection->handles($row->event_class)) {
continue;
}
// Deserialize through the version-aware serializer:
// a v1 row is migrated to v2 before the projection
// ever sees it.
$event = $serializers->for($row->event_class)->deserialize(
$row->event_class,
$row->event_properties,
(int) $row->event_version,
$row->metadata,
);
$projection->handle($event);
$replayed++;
}
});
$this->info("Replayed {$replayed} events into ".$projection::class);
return self::SUCCESS;
}
private function resolveProjection(string $class): ?Projection
{
if (false === class_exists($class)) {
$this->error("Projection class {$class} does not exist.");
return null;
}
$projection = app($class);
if (false === $projection instanceof Projection) {
$this->error("{$class} is not a ".Projection::class);
return null;
}
return $projection;
}
}
The point of this command is that it has to be safe to run. The truncate drops everything; the chunked replay rebuilds it. If the replay fails halfway through, the read model is half-built and the fix is to run the command again — which is why the handler must be idempotent.
The replay path is the central thesis of this article in code. Every event goes through the SerializerRegistry on the way to the projection, so a v1 OrderPlaced stored before the v2 serializer shipped is migrated to the v2 shape on read. The aggregate reconstitution path does the same. Both consumers of the log share one migration point; nothing downstream ever sees a stale shape.
Three failure modes that show up in production:
First, the rebuild takes longer than the deployment window. You truncate the read-model table, start replaying events, and ten minutes later the team is still waiting. Live traffic continues to read from a table that is half-built. The right answer is to build the new table alongside the old one, switch reads atomically when the rebuild completes, and drop the old one. The implementation is to have two projections registered simultaneously — one writes to the old table, one writes to the new — and a feature flag that controls which one the read path uses.
Second, the rebuild is non-idempotent. Replaying the same event twice creates two rows, or updates one row twice with different side effects, or both. The next time you rebuild — and there will be a next time — you cannot trust the result. The fix is the idempotency key above. The composite aggregateUuid:aggregateVersion is unique across the entire log; the projection uses it as the primary key on the read model, making the second replay a no-op. The cost is one column on the read model. The benefit is that you can run the replay command as many times as you want, and the result converges.
Third, the rebuild silently disagrees with the aggregate. The projection and the aggregate both process the same events but produce different state. This is the failure mode nobody catches until the postmortem. The defense is to assert in the projection that what it has computed matches what the aggregate would have computed — but the aggregate is in PHP and the projection is in SQL, so this is harder than it sounds. The pragmatic version is a periodic reconciliation job: pick a sample of aggregates, replay them from the event store, compare the resulting state to the projection, alert on divergence. This is not glamorous, but it is what makes the rebuild safety net work.
When rebuilds go wrong
Three concrete failure patterns, each with the fix that comes from a postmortem:
Live traffic races the rebuild. The rebuild runs in the background while live traffic continues. Events keep arriving and keep getting projected into the new table. By the time the rebuild catches up to live, the live writes have produced a few hundred extra rows that the replay never saw. The state at the moment of cutover is consistent; the state ten minutes after cutover is not. The right answer is a “double-write, atomic switch” pattern: write to both tables during the rebuild, switch reads at a known checkpoint, and replay only the events up to that checkpoint. The skeleton has no built-in support for this; you build it on top, usually by having two projections registered simultaneously during the rebuild window.
Event ordering across projections. The skeleton dispatches every event to every projection whose handles() returns true. If projection A expects events to arrive in order Placed, Paid, Shipped and projection B is registered later and processes them in parallel on the queue, B can see Shipped before Paid on a slow queue worker. The defense is to make every projection handle out-of-order arrivals: check whether the event makes sense given current state, fail (or skip with a log line) if not. The alternative is a global ordering lock that serializes all event handling, which works but degrades throughput by an order of magnitude.
Replay and the queue race. The skeleton queues projection updates for events fired after the rebuild started. If the rebuild is on the same queue worker that handles live event projections, the rebuild can pre-empt itself: live events arriving while replay is running get processed before the replay catches up to them, but the replay will then re-process them anyway because it walks the entire log. The idempotency key prevents double-writes, but the worker time is wasted. The fix is to run rebuilds on a separate queue or a separate worker pool, isolated from live traffic, with its own concurrency limits.
The decision: when event sourcing is wrong
Most systems should not use event sourcing. The pattern is expensive to adopt, expensive to maintain, and expensive to undo. The reasons that look compelling on day one — “we want an audit trail”, “we want to be able to replay state”, “we want temporal queries” — are often addressable by a simpler primitive. A PostgreSQL table with created_at and updated_at columns plus a trigger that writes to an audit table gives you 90% of the audit benefit at 5% of the operational cost. A read replica, a logical replication slot, and a sane backup policy give you most of the “we can rebuild from history” benefit without the read-model layer.
Event sourcing is the right call in a narrow set of cases:
- The audit requirement is the system, not a feature. Regulated industries where the state of every record has to be reproducible from immutable history, with cryptographic guarantees, and where the auditor's questions are not “what is the current state” but “what was the state at time T, and why did it change”. A bank reconciliation engine, a regulated trading system, an immutable medical record system. If you are not in this category, the audit table is cheaper.
- The business logic is the events. A logistics system where the workflow is “package picked up”, “package in transit”, “package out for delivery”, “package delivered” and the system needs to react to each transition in a way that depends on the previous transitions. The events are the domain. A CRUD model of the same system would force you to encode the workflow in triggers and stored procedures, which is its own nightmare.
- Downstream systems need to react to the same events. Multiple bounded contexts need to subscribe to the same event stream. The event store is the natural integration point. If the consumers are in the same monolith, this is just a projection. If they are in different services, this is where event sourcing pays for itself in ways that nothing else does.
Event sourcing is the wrong call when:
- The team cannot articulate the consistency boundary. Event sourcing without proper aggregate boundaries is a distributed monolith pretending to be event-driven. If “what is the consistency boundary here” is a question the team cannot answer, the result is a system that is harder to reason about than the database it replaced.
- The primary use case is ad-hoc querying. “Show me all orders from customer X placed in March with product Y” is a relational query. With event sourcing it is a projection. If the business needs a new query every quarter, you will be writing a new projection every quarter. A relational database would have been a
WHEREclause. - Eventual consistency is unacceptable. If a write must be immediately visible in a query, the read model needs synchronous write-through. The projection layer is no longer an optimization; it is a coupling point. The complexity of event sourcing is now sitting on the critical path of every request, with no payoff.
- The MVP needs to ship in two weeks. The infrastructure cost of event store, snapshot strategy, projection rebuild machinery, serializer versioning, and audit log is two months of work. If you can validate the business hypothesis without that, ship without it. Migrate later if the domain complexity justifies the move.
The reason the failure cases at the top of this article are common is that teams reach for event sourcing because the patterns look tractable, and discover in year two that the tractability was the skeleton, not the pattern. The pattern is hard. The pattern pays off only in the cases listed above. Outside those cases, it is an expense with no return.
What I would tell someone starting today
If you have decided event sourcing is the right call, get the runtime — skeleton or package — standing before you build the business logic. Whichever you pick, the benefit is that the production failure modes are visible in the plumbing itself, and you can decide which ones you are going to defend against before you have an aggregate that depends on the decision.
Build the serializer versioning on day one. The temptation is to defer it: “we have not changed the schema yet, we will build the serializer branching when we need it”. The problem is that by the time you need it, you have a year of stored events in v1 shape, no serializer branching, and a migration that has to run on the live database while the application is running. Build the version field on the event row on day one, even if every serializer is a pass-through. The cost is small and the option value is large.
Build the rebuild command before you need it. The “truncate the read model, replay from the event log, atomic switch” path is something you want to be able to do on day one. If you cannot, you will not do it when you discover a bug at year three, because by year three the rebuild is too expensive to be confident in. Build it while the log is small; the benefit is that the safety net actually exists.
Measure the cost of rehydration in production on day one. If you do not measure, you will discover the cost when an aggregate with ten thousand events needs to be retrieved, and the user is waiting. Build the snapshot strategy as part of the initial design, with a cadence chosen from the rehydration-cost measurement, not from a blog post rule of thumb. The cadence will be wrong the first time. Adjust it. Measure again. The right cadence is one that matches your latency budget, not one that matches mine.
Decide whether to use a package or build the skeleton yourself before you start. A mature package — Spatie's laravel-event-sourcing, EventSauce — has already solved and hardened the parts this article's skeleton keeps small: the write path, concurrency retries, serializer wiring, projector discovery, queued dispatch and ordering, replay isolation. The default answer for most teams is to use one, and to reach for a hand-rolled layer only when you have a concrete, named reason the package's opinions get in your way — usually that your domain is the competitive advantage and every engineer needs to understand every layer of it. Whichever you pick, the failure modes in this article are the same; what changes is how many of them you own.
Event sourcing is a pattern that punishes casual adoption and rewards serious adoption. The serious part is not the technology — the skeleton handles that. The serious part is understanding that snapshots, schema evolution, and read-model rebuilds are one problem in three places, and the discipline to keep all three in sync as the system grows. That discipline is what separates a working event-sourced system in year five from a write-only event log that nobody dares to rebuild.
Summary
Snapshots, schema evolution, and read-model rebuilds are coupled. A snapshot taken under v1 shape cannot be replayed over v2 events without invalidating first. A projection rebuilt from the event store silently disagrees with snapshots if the projection logic and the aggregate's apply logic have diverged. A schema change that adds a required field fails to load every snapshot taken before the field existed.
The practices that hold up in production are: take snapshots on a per-aggregate cadence derived from rehydration cost and latency budget, not from a rule of thumb; build the serializer versioning on day one; invalidate every snapshot when a serializer version ships; build the rebuild command before you need it, and key every projection on the aggregate uuid and version so replay is idempotent; reconcile the projection against the aggregate periodically, and alert on divergence; isolate the rebuild queue from the live event queue.
Most systems should not adopt event sourcing. The cases that justify it are narrow: regulated audit as the system, the events are the business logic, downstream systems consume the same event stream. Outside those cases, the pattern is an expense with no return, and the second year of production will surface that expense in the form of snapshots you cannot read, projections you cannot rebuild, and a team that has stopped touching the system because the cost of changing anything is unpredictable.
Event sourcing is a long-term commitment. The skeleton handles the technology. The discipline handles the system.