Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Eventstore Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Legacy Symfony project already calling EventStore over HTTP: install it and use the container service.
  2. Laravel or modern Symfony: read the bundle to learn the HTTP surface, then vendor/fork the handful of client classes behind an interface you own. This is realistically the day-to-day use for most teams.

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.

Implementation Patterns

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:

  • Plain array + type string in data, hydrate via match/switch in the repository. More portable, easier migration.
  • Symfony Serializer on DTOs, with 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:

  1. Reads the stream or $all forward from a checkpoint stored in your own application DB (last processed event number).
  2. Projects into read-model tables.
  3. Commits the new checkpoint in the same transaction.

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource