jphooiveld/eventsauce-bundle
A Symfony bundle that integrates EventSauce event sourcing into your application, providing service configuration for aggregates, repositories, message dispatching, and event storage so you can build domain-driven, event-sourced systems with less boilerplate.
First reality check: this is a Symfony bundle, not a Laravel package. composer require jphooiveld/eventsauce-bundle inside a Laravel app will pull Symfony's DI/HttpKernel components and the bundle will never boot. For a Laravel codebase you leverage this repo in one of two ways:
eventsauce/eventsauce directly and copy the bundle's service definitions into a ServiceProvider. The bundle is small — its value is showing you which EventSauce objects need wiring and in what order.composer require jphooiveld/eventsauce-bundle, register the bundle in config/bundles.php (Flex does it), then configure under config/packages/. Confirm the bundle FQCN with composer show jphooiveld/eventsauce-bundle before hand-editing bundles.php.Where to look first (in this order, ~20 minutes):
README.md — config keys and the consumer tag name.DependencyInjection/Configuration.php — the actual config tree; this tells you exactly what is tunable versus hardcoded.DependencyInjection/*Extension.php — default service IDs and aliases.Resources/config/* — the wiring you are about to reimplement in Laravel.CompilerPass — this is where tagged message consumers get collected; you need the tag name.Minimal Laravel service provider (mirrors what the bundle does, EventSauce 1.x API):
$this->app->singleton(MessageSerializer::class, fn () => new ConstructingMessageSerializer());
$this->app->singleton(MessageRepository::class, fn ($app) => new DbalMessageRepository(
$app->make(Connection::class)
));
$this->app->tag([SendInvoice::class, UpdateOrderList::class], 'eventsauce.consumer');
$this->app->singleton(MessageDispatcher::class, fn ($app) => new SynchronousMessageDispatcher(
...iterator_to_array($app->tagged('eventsauce.consumer'))
));
$this->app->bind(OrderRepository::class, fn ($app) => new AggregateRootRepository(
Order::class,
$app->make(MessageRepository::class),
$app->make(MessageDispatcher::class),
$app->make(MessageSerializer::class),
));
First use case: one aggregate (Order) with AggregateRootBehaviour, one command handler that calls retrieve() / persist(), an in-memory MessageRepository in tests, DBAL in production, and one projection consumer that writes a read model. Prove the loop end-to-end before adding a second aggregate — every later decision (serializer, outbox, snapshots) hangs off this loop.
One repository binding per aggregate root. Don't try to make a single generic repository; bind OrderRepository, CustomerRepository, etc. as thin aliases around AggregateRootRepository, each constructed with its own aggregate class name. This keeps the call sites honest ($orders->retrieve($id) returns an Order, not an AggregateRoot) and makes static analysis useful.
Command → handler → repository is the whole workflow. persist() stores messages and dispatches them, in that order. Everything downstream — projections, side effects, notifications — is a MessageConsumer reacting to the dispatch. Resist the urge to call consumers directly from controllers.
Projections as tagged consumers. Register each projector under the same tag and let the dispatcher fan out. In Laravel, prefer queued consumers for anything slow:
final class UpdateOrderList implements MessageConsumer
{
public function __invoke(Message $message): void
{
match (true) {
$message->event() instanceof OrderPlaced => dispatch(new ProjectOrderPlaced($message)),
default => null,
};
}
}
Outbox pattern is the integration point with Laravel's transaction model. The bundle ships a synchronous dispatcher, which means a throwing consumer leaves you with a committed write and a broken projection. In Laravel this is trivially fixable:
DB::transaction(function () use ($order) {
$messages = $this->orders->persist($order); // or capture via a decorator
// write same messages to a `event_outbox` table inside the transaction
});
// after commit: a job drains the outbox and re-dispatches to consumers
If you'd rather not capture return values, wrap your MessageRepository in a decorator that writes to the outbox table, and point MessageDispatcher at the drainer only.
Serializer is where schema evolution lives. ConstructingMessageSerializer round-trips through toPayload() / fromPayload() on your event classes. For day-to-day safety, wrap it:
new UpcastingMessageSerializer(
new ConstructingMessageSerializer(),
new DelegatingUpcaster([new EventTypeMigrated('order_placed.v1', 'order_placed.v2', UpgradePayload::class)])
);
Snapshots for long-lived aggregates. Wrap the repository in SnapshotAggregateRootRepository with a SnapshotRepository once an aggregate routinely replays more than a few hundred events (order timelines, ledger accounts). Persist snapshots to Redis or a dedicated table; they are disposable, so never treat them as the source of truth.
Testing pattern: swap MessageRepository for InMemoryMessageRepository in the test container and assert on the messages produced by a command. This keeps aggregate tests fast and free of DB setup, and it's the single biggest ergonomic win of following the bundle's structure.
AggregateRootRepository from a concrete class to an interface (with EventSourcedAggregateRootRepository as the implementation), among other signature changes. If you're on PHP 8.2+, expect --ignore-platform-reqs gymnastics on a Symfony stack — another reason to mine the patterns rather than install.event_type alias map from day one, and treat event class names as public API.AggregateRootBehaviour enforces expected version on write; concurrent commands throw. Catch in the command handler and retry a bounded number of times — don't let it bubble to the controller as a 500.UuidAggregateRootId and a custom implementation, or changing the ID class later, will fail deserialization on replay. Pick one and stick to it.eventsauce, event_sauce, or a nested event_sourcing block. Read Configuration.php rather than trusting the README, and note that service references are FQCN strings — typos there only surface at container compile time, which in Laravel means "silently resolves nothing" until you call $app->make().event_store table (aggregate_root_id, aggregate_root_version, payload JSON) before your first persist(), and index on the ID.retrieve() the aggregate and dump the message list, and eyeball the raw payload column — 90% of event-sourcing bugs are a payload shape that drifted.MessageDecorator chain for correlation/causation IDs and app version headers (put these on day one; retrofitting them into historical messages is impossible), the compiler-pass tag for consumers, and a custom MessageRepository if you want Eloquent rather than DBAL — the interface is three methods and small enough to implement directly.How can I help you explore Laravel packages today?