patchlevel/event-sourcing
A fully featured, developer-friendly event sourcing library for PHP built on Doctrine DBAL. Includes automatic snapshots, stream splitting, versioned subscriptions, crypto-shredding for personal data, event upcasting, and CLI commands.
Install and wire up
composer require patchlevel/event-sourcing
php artisan vendor:publish --tag="event-sourcing-config"
php artisan vendor:publish --tag="event-sourcing-migrations"
php artisan migrate
The package is framework-agnostic at its core with a Laravel service provider layered on top. The config file it publishes (config/event-sourcing.php) is the single most important file to read first — it controls the event store driver, serializer, snapshot store, subscription/queue behaviour, and the header/message serialization settings that the 3.22 line touches.
Where to look first
config/event-sourcing.php — drivers, serializers, snapshot config, metadata factory configuration.AggregateRoot base class and one or two example aggregates in the docs — this is the mental model everything else hangs off.stored_events / snapshot / subscription tables, which is the fastest way to understand what the package persists.First use case
Pick a single, well-bounded aggregate — a Cart, Order, or Account — and implement exactly one command path: record a Created event, persist via the repository, and project it into one read-model table with one projector. Do this before touching reactors, snapshots, or subscriptions. The value of event sourcing only becomes obvious once you have replayed that one projector from scratch, so build that loop early.
Aggregate-centric command flow
Commands resolve an aggregate from the repository, call a domain method that records events, and the repository persists them. Keep apply* methods pure state mutation — no I/O, no dispatch, no clocks — because they run both on the write path and during reconstitution from the stream. Put invariants in the recording method (throw before recording), not in apply.
Projectors vs reactors
upsert/updateOrInsert, never increment or insert with auto-increment assumptions. Design them so running the projector twice over the same stream produces the same table state.on(EventClass::class) style so the mapping is explicit and greppable; avoid magic string matching.Subscriptions for async work
Use a subscription per read model / consumer, with the package's checkpointing table, rather than ad-hoc "replay everything" scripts. Typical day-to-day flow:
php artisan event-sourcing:subscription <name> # run the consumer
# plus a catch-up/replay command provided by the package
Run subscriptions as supervised workers; they are stateful (they track position), so don't treat them like stateless queue workers you can kill mid-batch without checking the checkpoint behaviour of your configured store.
Snapshots
Add snapshots only for aggregates whose streams grow unbounded (thousands of events). Configure the snapshot store and a threshold in config, and implement the aggregate's snapshot state deliberately — snapshotting a partially-hydrated aggregate is a common source of subtle bugs. Snapshots are an optimisation, never a source of truth.
Headers / metadata and header aliases (3.22.0)
Headers (message metadata) are stored alongside events and must be deserialized back into header objects when the stream is read. The 3.22.0 change adds support for header aliases, i.e. you can map a stored alias string to a header class, decoupling persisted data from your PHP class names.
Day-to-day pattern this enables:
App\Headers\UserId → App\Domain\Headers\ActorId), register the old name as an alias so existing stored events still deserialize. This removes the long-standing sharp edge where renaming a header class silently broke replay of historical events.Practical advice: treat the alias map as part of your persistence contract — put it under version control, review it in PRs like a migration, and add an alias before you rename, never after. Keep one canonical alias per header class for new writes and reserve the extra aliases for reads of historical data.
Metadata factories
If you use (or write) metadata factories to attach headers to outgoing messages, keep them cheap and correct under caching. 3.22.1 fixes the cache keys used by the package's metadata factories (PR #930), so if you are on 3.22.0 and have caching enabled for metadata factories, upgrade before relying on cached metadata. When implementing a custom factory, make sure the cache key you hand back is derived from everything that affects the produced metadata (event/aggregate identity, user/correlation context) — two different messages must never share a key.
Testing
Header alias pitfalls (3.22.0)
app.user_id) rather than relying on bare class basenames.Metadata factory cache keys (fixed in 3.22.1)
Class renames in general
Both event classes and header classes are embedded in stored data. Any rename is a data migration. The safe sequence is always: deploy alias/map → deploy new class → backfill/upcast at leisure → remove old alias only when nothing historical references it.
Idempotency and replay
now() will diverge on replay. Keep time and randomness out of apply methods; derive timestamps from the event's recorded metadata.Transactions and consistency
Recording events and writing the read model are not atomic unless you deliberately make them so. Prefer async projectors with subscriptions for anything that can tolerate lag, and synchronous projectors only where a stale read model is genuinely unacceptable. Watch out for writing to the read model inside the same transaction as the event store — it couples your write latency to projection cost.
Config quirks
Extension points worth knowing
Upgrade guidance for 3.22.x
How can I help you explore Laravel packages today?