rxnet/eventstore-client
RxNet EventStore Client integrates EventStoreDB with Laravel, making event sourcing practical in PHP. Append and read events, work with streams, and build event-driven applications through a simple, framework-friendly client API.
Install the client and its async dependencies. The package is pure PHP, so it drops into a Laravel app without a native extension:
composer require rxnet/eventstore-client
You also need a running EventStoreDB server. This client speaks the legacy TCP protocol (tcp://host:1113), so you need EventStore 3.x/4.x/5.x — not the gRPC-only 20.x+ servers. For a local trial, run the old Docker image with the TCP port mapped:
docker run --rm -p 1113:1113 -p 2113:2113 eventstore/eventstore:5.0.9
Where to look first:
Rxnet\EventStore\EventStore — the entry point (connect, read, write, subscribe, delete, transactions).Rxnet\EventStore\Data\* — DTOs you actually construct: NewEventData, EventData, EventRecord, WriteResult, ReadResult.Rxnet\EventStore\Acknowledgeable — the wrapper returned from writes, exposing ack/nack observables.A minimal first use case — connect, append one event, read it back:
use React\EventLoop\Loop;
use Rxnet\EventStore\EventStore;
use Rxnet\EventStore\Data\NewEventData;
use Rxnet\EventStore\Data\EventRecord;
$es = new EventStore('tcp://127.0.0.1:1113', Loop::get());
$es->connect()->subscribe(
fn() => print "connected\n",
fn(\Throwable $e) => print "connect error: {$e->getMessage()}\n"
);
$event = new NewEventData();
$event->setEventId(/* uuid */);
$event->setEventType('order.placed');
$event->setData(json_encode(['orderId' => 42]));
$event->setMetadata(json_encode(['user' => 'system']));
$es->write('order-42', [$event])->subscribe(
fn($write) => print "written\n",
fn(\Throwable $e) => print "write error: {$e->getMessage()}\n",
fn() => $es->read('order-42', 10, 0, true, false)->subscribe(
fn(EventRecord $r) => print "read: {$r->getEventType()}\n"
)
);
Loop::run(); // the loop drives everything; without it nothing happens
The takeaway: nothing executes until the ReactPHP loop runs, and everything is an observable you must subscribe() to.
Bind one EventStore + one loop per process rather than per request. Put it behind a service provider and inject it:
$this->app->singleton(EventStore::class, function () {
return new EventStore(config('eventstore.dsn'), Loop::get());
});
config/eventstore.php should hold dsn, default_stream, credentials, and timeouts — keep them out of code.
ackwrite() returns an Acknowledgeable. Subscribing yields a WriteResult and an ack/nack channel. In application code, always attach both handlers so a broker-level reject doesn't get silently dropped:
$es->write($stream, [$newEvent])->subscribe(
function ($acknowledgeable) use ($log) {
$acknowledgeable->ack()->subscribe(
fn() => $log->info('event committed'),
fn($e) => $log->error('ack failed', ['e' => $e])
);
},
fn($e) => $log->error('write rejected', ['e' => $e])
);
write + subscribe for read models, not a full event busThe package is a client, not a message bus. The idiomatic pattern is: a Laravel job or command appends domain events to a per-aggregate stream (order-42), and a separate projection reads with read() or readAll() from a checkpoint. Because read() is paged, wrap it in a recursive Rx chain or a while loop that requests the next slice until you reach the end of the stream.
$es->subscribe($stream, $resolveLinkTos) gives you a live feed. Drive projections from that observable, keeping the last-seen eventNumber in a checkpoints table so you can resume. Subscribe from your checkpoint on restart — the package itself does not remember position.
The reactive flavour is the actual value of this package. You get .map(), .filter(), .flatMap() and friends over event streams, so a projection becomes:
$es->read('order-42', 1000, 0, true, false)
->filter(fn($e) => $e->getEventType() === 'order.placed')
->map(fn($e) => json_decode($e->getData(), true))
->subscribe(fn($payload) => $this->applyPlaced($payload));
That's cleaner than a foreach for aggregating/streaming logic — lean on it.
Run the client inside php artisan commands, queue workers, or a dedicated projection process. For request-scoped operations, either:
->wait() on the observable (acceptable for small reads/writes, but you pay a loop-startup cost per request).EventStore exposes transaction helpers, but for the 90% case write() with expectedVersion (via the stream options) is enough. Use explicit expected versions to get optimistic concurrency — the server will reject on mismatch and your nack handler should retry or surface a domain conflict.NewEventData in a small factory that maps your PHP value objects to event type + JSON payload + metadata (including correlationId, causationId, user). This is where you keep your Laravel app idiomatic.EventStore behind your own interface (an in-memory append/read) rather than mocking Rx graphs — observables are painful to stub.react/event-loop and rx/observable APIs. On PHP 8.1+ and current ReactPHP you may hit signature/type issues. Pin versions or be prepared to shim.Loop::run() (or ->wait()) yields a silent no-op — writes appear "lost," reads return nothing, and no exception is raised because the promise never settles. Symptom: process exits cleanly with empty output.subscribe() without an onError handler disappears. Always pass both error and completion callbacks, especially on connect() and write(). Add a global loop-level error logger.connect()/subscribe() in a supervisor with exponential backoff and resume from your own checkpoint.resolveLinkTos matters. If you emit $> links, reading without resolveLinkTos = true returns link records, not events — the most common cause of "my event type is $>" confusion.onNext on a live feed without batching/queueing; buffer to a queue and drain.expectedVersion is your concurrency control. Use it. Silent last-write-wins is the default otherwise and will corrupt aggregate histories.EventStore bound as a singleton plus a shared loop is not safe across Octane requests — the loop lifecycle and connection state get confused. Keep event-store access in dedicated CLI/queue workers, or scope a fresh client per request.EventStore constructor takes a loop. Inject a loop you control (e.g. from a supervisor command) so you can add signals, timeouts, and health checks around it instead of letting the client own it.NewEventData requires setEventId, setEventType, setData before write; missing fields fail late, at protocol level, not at object construction. Validate in your factory.Overall: an elegant reactive client that showcases RxPHP nicely, but it targets a protocol generation EventStoreDB has largely left behind. Use it if you already operate an old EventStore cluster or are learning event sourcing; for greenfield work, treat it as a reference rather than a foundation.
How can I help you explore Laravel packages today?