patchlevel/event-sourcing-bundle
Symfony integration for the patchlevel event-sourcing library, built on Doctrine DBAL. It bundles snapshots, stream splitting, versioned subscriptions, upcasting, personal-data crypto-shredding, schema/migration setup, and CLI commands for a smooth developer experience.
Read this first: patchlevel/event-sourcing-bundle is a Symfony bundle, not a Laravel package. There is no auto-discovery, no config/event_sourcing.php, and no Artisan commands. Day-to-day in Laravel you will almost always install the underlying library directly and wire it yourself:
composer require patchlevel/event-sourcing doctrine/dbal
Take the bundle only if you are running Symfony in the same monorepo, or if you intend to read its DependencyInjection/ extension as a wiring blueprint — it is a good one, because it is just a thin, fully-typed factory layer over the library. That said, every release widens the gap: 3.18.0 adds a metadata cache, profiler integration and an AggregateIdValueResolver to the bundle's DI layer, none of which exist in a Laravel app. Read it for ideas, not for code you can lift.
Where to look, in order:
event-sourcing.patchlevel.io — the library docs are excellent and versioned. The bundle README is mostly a feature list; the real substance lives upstream. Note that the bundle's own config docs (expanded in 3.18.0) describe a Symfony config tree you will be translating by hand.#[Aggregate], #[Projection], #[Subscribe], #[Processor] attributes — the whole DX of this package is attribute-driven, and reading those classes tells you the public contract faster than any tutorial.Resources/config/services.php — this is your map of "which classes do I need to bind in a Laravel service provider." In 3.18.0 this is also where the new metadata cache service is defined, which is worth copying.AbstractAggregateRoot — aggregates are pure objects, so the tests double as documentation.Minimal first use case — an aggregate plus its events, tested entirely without a database:
use Patchlevel\EventSourcing\Aggregate\AggregateRoot;
use Patchlevel\EventSourcing\Attribute\Aggregate;
use Patchlevel\EventSourcing\Attribute\AggregateIdentifier;
#[Aggregate('order')]
final class Order extends AggregateRoot
{
#[AggregateIdentifier]
private string $id;
private OrderStatus $status;
public static function create(string $id, string $customerId): self
{
$self = new self();
$self->recordThat(new OrderCreated($id, $customerId));
return $self;
}
public function pay(string $paymentId): void
{
if ($this->status !== OrderStatus::Pending) {
throw new \DomainException('Order is not payable.');
}
$this->recordThat(new OrderPaid($this->id, $paymentId));
}
protected function applyOrderCreated(OrderCreated $event): void
{
$this->id = $event->id;
$this->status = OrderStatus::Pending;
}
protected function applyOrderPaid(OrderPaid $event): void
{
$this->status = OrderStatus::Paid;
}
}
Then, in a service provider, bind a DoctrineAggregateRootRepository for Order against a DBAL connection, and in a command handler do load() → mutate → save(). Get that loop green before you touch projections.
One aggregate load per command. The canonical unit of work is:
$order = $this->orders->load($orderId); // replays (or snapshots) the stream
$order->pay($paymentId);
$this->orders->save($order); // appends new events
Command handlers should do exactly this and nothing else. No writes to read tables, no mail, no HTTP. If a handler needs data from a projection to make a decision, that is a design smell — load the aggregate and let its invariants answer the question.
Projections are separate, idempotent consumers. In Laravel, define them as plain classes with #[Projection('order_list')] and #[Subscribe(OrderPaid::class)] methods, then run them off a subscription worker. The subscription engine persists a position per projection, so a crash mid-batch resumes rather than double-applies — but only if your handlers are idempotent (upsert, updateOrInsert, INSERT ... ON CONFLICT DO NOTHING). Treat that as non-negotiable.
Worker wiring for Laravel. There is no artisan event-sourcing:*. Wrap the library's subscription runner in a custom Artisan command and supervise it with Horizon/Supervisor/systemd, e.g. php artisan es:subscribe order_list --limit=1000 --until-idle. Two practical patterns:
--time-limit and a restart on deploy.->everyMinute() with withoutOverlapping()) for low-volume domains where a daemon is overkill. This is often the right first choice in a Laravel app.Cache the attribute metadata (new in 3.18.0). The bundle now ships a metadata cache so the attribute driver does not re-reflect your aggregate and projection classes on every boot. Laravel has nothing equivalent out of the box, and it matters most exactly where you need it — long-lived subscription workers that restart on deploy and then re-scan every class. Back the library's metadata factory with a PSR-16 cache (Laravel's cache.store() adapter, or a small Illuminate\Contracts\Cache\Repository decorator) and flush that key on deploy:
$this->app->singleton(MetadataFactory::class, fn ($app) => new CachedMetadataFactory(
new AttributeMetadataFactory(),
new Psr16CacheAdapter($app['cache']->store('array')), // or a tagged Redis store
));
Two rules: never cache to a store shared with php artisan cache:clear in the middle of a worker's batch without a restart, and always bust the cache on deploy — stale metadata after you rename an event class produces failures that look like deserialization bugs.
DBAL connection mapping. Laravel's database config keys do not match DBAL's. Map them explicitly and reuse the same credentials, but do not try to hand Laravel's PDO instance to DBAL:
$params = config('database.connections.pgsql');
$connection = DriverManager::getConnection([
'driver' => 'pdo_pgsql',
'host' => $params['host'],
'port' => $params['port'],
'dbname' => $params['database'], // note: 'database' → 'dbname'
'user' => $params['username'],
'password' => $params['password'],
]);
Register it as a singleton so the event store, snapshot store, and subscription store all share one connection — otherwise you get three pools and confusing transaction boundaries.
Testing. This is where the package earns its keep. Aggregates are pure state machines, so:
$order = Order::create('o-1', 'c-1');
$order->pay('p-1');
self::assertSame(OrderStatus::Paid, $order->status);
For repository-level tests, use the library's in-memory event store — no migrations, no DB, milliseconds per test. Keep an integration test suite (real Postgres/MySQL) that runs the same scenarios, because serialization and upcasting bugs only appear against a real store. If you add a metadata cache, run the suite with the cache enabled at least once — a cache that never invalidates is only visible under test.
Snapshots and split streams are configuration, not code. When a stream gets long (hundreds of events) or an aggregate gets hot, enable snapshots for that aggregate and let the library decide when to write one. If a single aggregate still grows unbounded, model the split stream — one stream per order line hanging off the order stream — rather than fighting the framework.
Upcasting for schema evolution. When an event's shape changes, add an upcaster and bump the event version. Old events stay untouched in the store and are transformed on read. This is the single most valuable feature for long-lived systems, and the reason to prefer this package over hand-rolled event tables.
The Symfony coupling is real — and growing. The bundle's services, config tree, and console commands assume a Symfony kernel. 3.18.0 adds two more Symfony-only surfaces: profiler integration (the web profiler panel showing streams and projections) and AggregateIdValueResolver (which injects an aggregate ID into Symfony controller arguments). Both are lovely in Symfony and useless to you — there is no equivalent for Laravel's container or route model binding. Attempting to register the bundle inside Laravel gets you a container full of services that reference Symfony\Component\DependencyInjection and EventDispatcherInterface in ways Laravel's container will not satisfy cleanly. Budget a day to write a ~150-line service provider that binds the event bus, command bus, snapshot store, metadata cache, and one repository per aggregate. It is tedious but mechanical, and the bundle's own service definitions are a copy-paste template.
Never put side effects in projections. Projections replay. A projection that sends an email sends it again on every reset. If you need "react to this event externally," that is a #[Processor] (or a Laravel queue job dispatched from a processor), not a projection. Write this rule on the wall.
Read models are eventually consistent — plan the UX. A user pays, gets redirected to an order page backed by order_list, and sees "pending" because the worker hasn't ticked. Options: (a) read the aggregate directly for the post-command redirect, (b) sync the projection inline for that one request, (c) show a spinner. Do not silently accept the flash-of-stale-state; decide explicitly.
Crypto-shredding is irreversible by design. Personal-data handling works by encrypting PII into event payloads with a per-subject key and deleting the key on erasure. Forget the key-management step (and test it) and you will discover on your first GDPR request that you have either permanently lost data or permanently retained it. Also: the moment PII leaks into a projection table, shredding the event store does not help — projections need the same treatment.
Schema management is not migrations-as-you-know-them. The store tables are created by the library's schema tooling, which is Doctrine-based. In Laravel you will want a one-off Artisan command that creates the tables, plus a checked-in SQL snapshot, rather than trying to reconcile the library's schema diffing with Laravel migrations. Keep the event store in a separate database or at least a separate connection if you can — it makes php artisan migrate:fresh less terrifying.
Event payloads must be boring. Strings, ints, arrays, DateTimeImmutable formatted as ISO strings. No Eloquent models, no entities, no closures, no Carbon instances with timezone surprises. Everything in an event is a permanent serialization contract; treat each field as a public API you cannot change without an upcaster.
Snapshots go stale when apply* logic changes. A snapshot is a cached aggregate state. Change how applyOrderPaid computes a field and old snapshots still reflect the old logic. After changing aggregate internals, rebuild snapshots for affected aggregates — and make sure your deploy runbook says so, because the bug it produces is subtle and data-shaped. The same discipline now applies to the metadata cache: a deploy that renames or moves event classes must bust it.
Small community, small blast radius. A narrow dependent list means: excellent code quality and type coverage, but you will be reading source rather than Stack Overflow, and the API can move between majors. The 3.18.0 changelog is a good example of the cadence — five PRs, one contributor, one feature — so read the diff of each minor before bumping. Pin the major version, keep your aggregate layer thin, and keep domain events as plain DTOs — then a future swap out of the library is a wiring change, not a rewrite.
When not to use this. If your domain is CRUD with an audit log, use spatie/laravel-activitylog and save yourself the operational surface. Event sourcing buys you temporal queries, replayable read models, and a complete audit trail — and charges you eventual consistency, replay discipline, and a worker to babysit. Make sure you are actually spending the money on something.
How can I help you explore Laravel packages today?