friendsofouro/kurrentdb-core
A PHP core library for KurrentDB (formerly EventStoreDB) that provides stream operations, event sourcing primitives, subscriptions, and a gRPC client for building reliable event-driven applications.
Install and connect.
composer require friendsofouro/kurrentdb-core
The package is an HTTP client, so it needs a PSR-18/Guzzle transport plus a running KurrentDB node (default HTTP port 2113; the dev container ships admin / changeit). Put credentials in .env:
KURRENTDB_URL=http://kurrentdb:2113
KURRENTDB_USERNAME=admin
KURRENTDB_PASSWORD=changeit
Bind the client once in a service provider rather than new-ing it in controllers — the stream/HTTP resources it holds are cheap, but centralising construction is what lets you swap the transport in tests:
// app/Providers/KurrentDbServiceProvider.php
$this->app->singleton(KurrentDbClient::class, fn () => new KurrentDbClient(
httpClient: new GuzzleHttpClient(), // or app(ClientInterface::class)
baseUri: config('kurrentdb.url'),
credentials: new BasicAuth(
config('kurrentdb.username'),
config('kurrentdb.password'),
),
));
Where to look first. The src/ tree mirrors the KurrentDB HTTP API surface, so the fastest orientation is to skim it side by side with the official HTTP API docs (/streams, /projections, /subscriptions, /users). Check the README examples and the constructor/factory class for how the client is assembled — the exact class names move between releases, so treat the snippets below as shape, not gospel. There is no Laravel service provider in this "core" package; you write the binding yourself.
First use case. The 15-minute win is append-then-read: write one event to a stream and read it back, which proves transport, auth, and content negotiation in one go.
$client->streams()->appendToStream('orders-'.$orderId, [
[
'eventId' => (string) Str::uuid(),
'eventType' => 'OrderPlaced',
'data' => ['orderId' => $orderId, 'total' => 4200],
'metadata' => ['correlationId' => $requestId],
],
]);
$events = $client->streams()->readStream('orders-'.$orderId);
If that round-trips, everything else in this package is the same pattern with more headers.
Wrap it behind your own interface. With zero dependents and a package that tracks a still-moving server API, the highest-leverage move is a thin EventStore interface in your app (append, read, subscribe) with this package as the one implementation. Every snippet below then lives in one adapter class instead of scattering the client across your codebase.
Domain events as an envelope. Keep a consistent payload contract so projections never have to guess:
final readonly class StoredEvent
{
public function __construct(
public string $id,
public string $type,
public array $data,
public array $metadata,
public int $revision,
) {}
}
Map your Laravel domain events to this on the way in and back on the way out. eventType should be a stable string (order.placed.v1) — projections and persistent subscriptions filter on it, and renaming it later is a migration.
Stream naming discipline. Use {category}-{id} for aggregate streams (order-{uuid}) and lean on KurrentDB's category projections ($ce-order) for cross-aggregate reads. Decide the convention on day one; retrofitting it means rewriting every projector.
Optimistic concurrency is the whole point. Pass the revision you expect and let the server reject the write:
$client->streams()->appendToStream($stream, $events, expectedRevision: $aggregate->version);
Catch the wrong-expected-version error, reload the aggregate, re-apply the command, retry — with a bounded attempt count. This is your aggregate's transaction boundary; do not append without it on aggregate streams.
Batch, don't loop. The HTTP API accepts an array of events in one POST. If a command produces three domain events, send one request with three events — you get atomicity within the stream for free and one round trip instead of three.
Idempotent event IDs. Derive eventId deterministically (e.g. UUIDv5 of stream + revision) rather than generating a fresh UUID inside a retry loop. KurrentDB deduplicates on event ID, so a retried append after a network timeout won't duplicate the event.
Read models as queue workers. The natural Laravel shape: an Artisan command that subscribes to $all (or a category stream), filters by event type, and dispatches a job per event to update Eloquent read models. Run it under Supervisor, checkpoint the last processed position (in Redis or a DB table) so restarts resume rather than replay from zero.
foreach ($client->streams()->subscribeToAll(fromPosition: $checkpoint) as $event) {
UpdateReadModel::dispatch($event);
$this->checkpoint->set($event->position);
}
Persistent subscriptions for competing consumers. If you need multiple workers sharing one consumer group with server-side acks (rather than each worker replaying), use the persistent subscription endpoints and ack/nack explicitly. Pick this over catch-up subscriptions when you scale out; pick catch-up subscriptions when you want simple, replayable, per-worker streams.
Metadata for observability. Stuff correlationId, causationId, and actorId into event metadata at append time and propagate them. When something goes wrong three services deep, the event log is the only place the trace exists.
Testing. The client speaks HTTP, so Http::fake() (or a Guzzle MockHandler) intercepts everything. Assert on the request body and the ES-ExpectedVersion header, and return canned Atom/JSON responses for reads. Keep a small fixture set of real responses captured from a dev node — the response shapes (especially embedded $all reads) are fiddly to hand-write.
Content negotiation bites. The HTTP API has several media types and the default Accept returns Atom XML, not JSON. If you get XML back, you sent the wrong Accept/Content-Type. Reads usually want application/vnd.eventstore.atom+json with ?embed=body (or embed=tryharder) so event bodies are inline rather than requiring an N+1 follow-up per event. Verify what the client sets for you before hand-rolling calls.
Expected-version semantics are off-by-one-feeling. -1/any means "no check", 0 means "stream must not exist", and n means "stream must be at revision n". Mixing up 0 and no-check is how you get duplicate aggregate-created events. Assert this in a test.
Long polling and timeouts. Subscriptions hold the connection open. Set the HTTP client timeout comfortably above the server's long-poll window, or you'll get a stream of reconnect noise that looks like packet loss. Conversely, don't leave the timeout at infinity — dead connections need to be recycled.
Auth over TLS only. Basic auth is the HTTP API's mechanism; shipping credentials over plain HTTP in production is a non-starter. Also note KurrentDB's user/ACL model is per-stream — a 403 on read is a permissions problem, not a client bug.
$all reads are not a firehose of your events. System events, projection checkpoints, and internal streams are in there. Filter by eventType server-side where possible, and always filter client-side as a belt-and-braces measure.
No distributed transactions. Multi-stream atomicity is not a thing here. If a command must touch two aggregates, model it as a process manager / saga with compensating events, not as a "just loop over both streams" append. Partial failure will eventually happen.
Version pin. This is a small, young package (69 stars, no dependents, release date in the future of most lockfiles you'll write). Pin an exact version and read the changelog on upgrade — API-surface churn is the realistic risk, not bugs.
Debugging recipe. Attach a Guzzle middleware that logs method, URI, headers, and body to a dedicated channel. When the client behaves oddly, reproduce the same call with curl -v -u admin:changeit -H 'Content-Type: ...' ... — nine times out of ten the diff between the two is the bug, and it's usually a header.
Extension points. The transport is injectable, so retry/backoff, correlation-ID injection, and metrics all belong in middleware or a decorator around the client — not inside your aggregates. Treat the client as replaceable infrastructure; your domain code should not know it exists.
How can I help you explore Laravel packages today?