Event sourcing · PHP

Event sourcing in PHP without the framework tax.

Most teams reach for a heavyweight framework the moment someone says "event sourcing," and quietly inherit its entire worldview along with it. You can get the guarantees you actually want — append-only truth, replayable state, an audit trail that is the product — with far less ceremony than the ecosystem suggests.

This essay is the version I wish someone had handed me before my first production system. It assumes you know why you want events; it's about keeping the machinery honest.

Note

This is written for PHP teams, but nothing here is language-specific. The Go version has fewer moving parts and the same failure modes.

The ledger is the point

An event store is an append-only log. That single constraint — you may add, never mutate — is where every downstream guarantee comes from. When "what happened" is more valuable than "what is," you keep the history and derive the state.1

The state you show users is a projection: a fold over the log. Delete it, replay the log, get it back. That property is worth more than most feature work.

An event store you can't replay confidently is just a slower database with worse ergonomics.

Projections, not tables

Treat each read model as disposable. The moment a projection becomes irreplaceable, you've reintroduced the mutable-state problem you were trying to escape. Here's a minimal projector:

OrderProjector.php
final class OrderProjector {
public function apply(Event $e): void {
match($e::class) {
OrderPlaced::class => $this->insert($e),
OrderShipped::class => $this->markShipped($e),
default => /* ignore */ null,
};
}
}

Note the default => null. A projector that throws on unknown events can't survive schema evolution. Ignore what you don't understand yet.

Versioning without tears

Events are forever, so their shape is a contract. Three rules keep that contract cheap:

  • Only ever add optional fields. Never remove or repurpose.
  • When a real change is unavoidable, write a new event type and an upcaster that maps old to new on read.
  • Keep serialization boring — explicit, versioned, and tested against fixtures of real historical payloads.
Field note

The first time you replay two years of production events into a new projection and the numbers match the old system to the cent — that's the moment the discipline pays for itself.

Snapshots are a cache, nothing more

Replaying from the beginning gets slow. Snapshots fix that, but treat them strictly as a cache: deletable, rebuildable, never a source of truth. If a bug means you can’t trust a snapshot, you throw it away and replay.

diagram — command → events → projections
Fig. 1 — Commands produce events; projections fold events into read models.
On replayable state — podcast guest spot
The Architecture Hour, ep. 112 · 55:20
55:20
0:00Why the log is the truth
10:40Projections as caches
33:00Where it stops being worth it
TODO — owner to supply episode file and show link.

Where it stops being worth it

Event sourcing is a cost. You pay in tooling, onboarding, and a mental model most engineers haven't internalized. It pays back only where history has value. A comparison I keep on hand:

ConcernCRUDEvent-sourced
Audit trailBolted onFree
Read modelsOneMany, disposable
Onboarding costLowHigh
Debugging past stateGuessworkReplay

If the top two rows don't matter to your domain, don't do this. Use a database and get on with your life.2

Closing

Keep the log honest, keep projections disposable, keep snapshots as cache. Do that and event sourcing stops being a framework you serve and becomes a property your system has. That’s the whole trick.

Noten
  1. 1. "Deriving state" is just reduce() over your history. The scary word is the friendly function.
  2. 2. TODO — owner to link the internal decision-record template referenced in talks.