th3mouk/eventstore-bundle
A Symfony bundle that integrates an event store into your application, providing configuration, services and console commands for persisting and replaying domain events. It keeps event-sourcing setup minimal so you can focus on aggregates and projections instead of infrastructure.
Reality check before install. Despite the "PHP package" framing, this is a Symfony bundle, not a Laravel package. It ships a DependencyInjection/Extension and service definitions for Symfony 2.x/3.x-era kernels. In a Laravel app you will not composer require it and wire a service provider — there isn't one. Two honest options:
The bundle is also archived with ~5 stars and 0 dependents. Pin it, expect to own it, and plan to wrap the transport.
Legacy Symfony install.
composer require th3mouk/eventstore-bundle
Register it manually (no config/bundles.php auto-registration on the kernels this targets):
// app/AppKernel.php
$bundles = [
// ...
new Th3Mouk\EventStoreBundle\Th3MoukEventStoreBundle(),
];
Configure the endpoint and Guzzle options:
# app/config/config.yml
th3_mouk_event_store:
host: 127.0.0.1
port: 2113
# client: { timeout: 5 } # passed through to Guzzle
Then pull the constructed client (service id follows the bundle's DependencyInjection/ naming — check that directory first; it's commonly th3mouk.eventstore.client):
$client = $this->get('th3mouk.eventstore.client');
First use case — round-trip one event. Append to a fresh stream, then read it back. A successful round trip validates credentials, reachability, content negotiation (must send Accept: application/vnd.eventstore.events+json and Content-Type: application/vnd.eventstore.events+json, otherwise EventStore returns Atom XML), and stream naming:
$client = $this->get('th3mouk.eventstore.client');
$client->writeStream('user-42', [
['type' => 'user.created', 'data' => ['id' => 42, 'email' => '[email protected]']],
]);
$events = $client->readStream('user-42');
Where to look first. The bundle layer is thin — read DependencyInjection/ to confirm the real service id and config keys, then read the underlying th3mouk/eventstore-php client class for actual method signatures and header handling. The HTTP semantics live in the library, not the bundle. Vendor and pin that library deliberately; nothing upstream will be patched.
Laravel equivalent of "getting started": create App\EventStore\EventStoreClient (an interface), then a GuzzleEventStoreClient implementation using the same writeStream/readStream shape. Bind it in a service provider so the rest of your code never imports bundle namespaces.
Stream-per-aggregate. One EventStore stream per aggregate instance (order-{uuid}). Always write with an expected version so concurrent commands fail loudly instead of silently interleaving:
$client->writeStream("order-{$id}", $newEvents, $expectedVersion); // or EventStore::EXPECT_ANY
The command loop is deliberately boring: read the stream from offset 0 → fold events into the aggregate → decide → append. Every write path should look the same.
Repository wrapper around the client. Never let controllers or jobs call the client directly. Introduce OrderRepository that owns stream naming, event type strings, and (de)serialization, with the client injected. This is your single seam for mocking in tests, swapping transports (HTTP → gRPC later), and adding instrumentation — critical because the bundle exposes almost no abstraction to hook into.
final class OrderRepository
{
public function __construct(private EventStoreClient $client) {}
public function save(Order $order): void
{
$this->client->writeStream(
"order-{$order->id()}",
array_map(fn ($e) => $this->serialize($e), $order->pullEvents()),
$order->version()
);
}
public function get(string $id): Order
{
return Order::rehydrate(
$this->client->readStream("order-{$id}")
);
}
}
Serialization is yours to own. The bundle moves arrays; it does not map events to objects. Pick a convention on day one:
type string in data, hydrate via match/switch in the repository. More portable, easier migration.type as the class discriminator. Less boilerplate, more coupling.Either way, put a schema_version in data from the first write. EventStore is append-only — you will be reading year-old payload shapes.
CQRS read side with explicit projections. There is no projection manager. Write a console command (cron or queue worker) that:
$all forward from a checkpoint stored in your own application DB (last processed event number).Keeping the checkpoint in your DB — never in EventStore — makes a full rebuild a DELETE FROM readmodels; DELETE FROM checkpoints; plus a replay.
Snapshotting. For long streams, persist a snapshot every N events alongside the current version, and on load read "latest snapshot ≤ version" then fold forward from there. Implement in the repository, not the bundle — snapshots are a domain concern, not a transport concern.
Testing. Because the seam is the repository, write an InMemoryOrderRepository that keeps event arrays in a PHP array and drives the same aggregate in tests. Contract-test it against the Guzzle-backed one in a single integration test pointed at a real or dockerized EventStore.
Idempotency you add. The bundle gives you raw Guzzle calls — no idempotency key, no dedupe. If you retry a write after a timeout, you can double-append. Wrap writes: include a UUID per event in data and dedupe on read, or use expected-version to make the retry fail and check "does the stream already contain this event id?" before re-writing.
Archived status is the dominant gotcha. No releases, no PHP 8 work, no Guzzle 7 support, no CVE response. Expect composer install conflicts on the Guzzle major, PHP floor, and Symfony component constraints; the "fix" is usually a fork or --ignore-platform-reqs, both of which are red flags in production. Budget permanent, low-but-nonzero maintenance as 100% yours. The mitigation is that the code volume is small — a few hundred lines of HTTP glue.
Content negotiation silently changes your response type. Forget Accept: application/vnd.eventstore.events+json, and EventStore happily returns Atom XML. Your JSON decode fails with a confusing error, not a 4xx. Log the raw response the first time you wire a stream.
Expected-version semantics. Pass EXPECT_ANY for "I don't care," or the concrete version you read. A wrong expectation produces a 400 with WrongExpectedVersion — inspect the body, not just the status, before concluding "network error."
No retry, backoff, or connection pooling out of the box. Everything inherits Guzzle defaults: short timeouts, no backoff, connection exhaustion under load. Harden in your adapter: HandlerStack middleware for retry with jitter, a longer connect timeout than default, and a persistent connection pool if you're appending at any real rate.
HTTP/AtomPub ceilings. The transport is chatty and batching is limited. For high-throughput appends the ceiling is materially lower than EventStoreDB's gRPC path — model expected event volume before committing architecture to it.
Subscriptions are polling, not push. The thin client exposes reads; there's no built-in subscriber. Build a polling worker that advances a checkpoint, and expect to handle stream not found / no events as a normal sleep case, not an error.
Service id and parameter names vary by version. Confirm in DependencyInjection/ rather than trusting docs or blog posts — this is a 5-star, unmaintained bundle and older examples circulate. When Laravel-izing, don't try to replicate the string id; just bind your own interface.
Debugging HTTP. Attach a Guzzle history middleware temporarily:
$stack = HandlerStack::create();
$stack->push(Middleware::history($container));
$client = new \GuzzleHttp\Client(['handler' => $stack]);
Dump request method, URL, headers, and body the first time a write fails — the bundle hides most of this, so surfacing it is your fastest path from "500" to "wrong expected version" or "wrong content type."
Extension points are your seams, not the bundle's. The practical extensibility story is: wrap the client behind your own interface, add middleware for retry/idempotency/auth/telemetry, and leave the bundle's own surface untouched so you can drop it the day you migrate to gRPC or prooph/event-sauce with a custom store. Keeping domain code dependent only on your interface is what makes that swap a one-file change instead of a refactor.
How can I help you explore Laravel packages today?