somework/cqrs-bundle
A CQRS bundle for Laravel that separates commands from queries with handler registration, buses, and middleware. Structure write and read flows cleanly while keeping integration lightweight and framework-friendly.
This is a Symfony bundle built on Symfony Messenger, not a Laravel package. If you are on Laravel, treat it as a reference implementation of CQRS + outbox patterns rather than something you can drop into app/Providers; the compiler-pass, bundle, and Messenger wiring has no Laravel equivalent. On Symfony, the path is short:
composer require somework/cqrs-bundle:^0.5.1
config/bundles.php.config/packages/cqrs.yaml and decide up front whether you need the outbox. The outbox is the feature with the most operational consequences and the one this release changed.transports.command_async / transports.event_async and start a worker.Look first at UPGRADE.md and the bundle's configuration reference, then at the cqrs log channel. If you are coming from 0.5.0 on Symfony 8.2, stop and upgrade to 0.5.1 first — 0.5.0 is not functional there.
The first real use case to validate is an outbox-backed command that writes a row and lets the relay deliver it. That exercises the transaction boundary, the transport routing, and the middleware stack in one go.
Bus-first dispatching. Commands and queries go through the bundle's buses; handlers are registered via attributes and Messenger's handler locator. Keep controllers thin: dispatch a command, return a response. The bundle's middleware (causation id, OpenTelemetry, trace context capture, deduplication lock release) is attached to those buses, so bypassing the bus loses observability and deduplication.
Outbox for at-least-once delivery. Use the outbox when a database write and a message must not diverge. OutboxWriter::store() is the explicit entry point; messages stored through a bus also land in the outbox when it is enabled. The relay then publishes rows to their transport. This is the Symfony analogue of Laravel's DB::afterCommit() dispatch pattern, but with persistence and replay built in.
Transport routing is configuration, not code. transports.command_async and transports.event_async decide where outbox rows go. As of 0.5.1 this also applies when there is no async bus — previously those rows had no transport, were handled in the relay process, and silently ignored DelayStamp. If you want the old in-relay behavior deliberately, point the transport at sync://.
Attribute-driven routing. #[AsMessageHandler(transport: …)], #[Asynchronous], and #[Outbox] are honored; 0.5.1 fixes their routing on Symfony 8.2. Prefer explicit transport: over relying on the default async transport.
Middleware extension. Add custom middleware through the bus configuration. Because the bundle's own passes now run at priority −24 (after Messenger's MessengerPass), your middleware sees a fully built bus, including handler locators and routing.
Testing. Point transports at sync:// or in-memory transports in the test environment and assert on handler effects rather than on relay timing. For outbox tests, assert the row is written in the same transaction as the domain change, then invoke the relay explicitly.
MessengerPass moved to priority −16, so CQRS middleware was missing from buses, handlerless events threw NoHandlerForMessageException, EnvelopeAware handlers failed with "Messenger envelope has not been set", the container failed to compile with the outbox enabled, and #[AsMessageHandler(transport: …)] routes were ignored. Upgrade to 0.5.1 before the Symfony 8.2 upgrade, not after.cqrs log channel pass at −25. Any application pass that must observe or mutate the bundle's middleware on a bus needs a priority below −25, or it will run too early.LogicException at build time is diagnostic, not a regression. It means MessengerPass had not built the buses when the bundle's passes needed them. Check for a competing pass ordering or a removed Messenger bundle.transports.command_async / transports.event_async, so their handlers run in that transport's worker. You must run messenger:consume <transport> for it, or set the transport to sync:// to retain relay-process handling.DoctrineDbalTransactionMiddleware and DoctrineDbalOpenTransactionLoggerMiddleware are skipped regardless of service id — they run in the relay instead. Do not assume they wrap the bus dispatch.cqrs log channel for bundle-level traces, debug:messenger to inspect buses and handler wiring, and messenger:consume output to confirm which transport a row reached. Missing middleware usually shows up as absent causation ids or trace context, not as an exception.^0.5.1; the release has no API or configuration breaking changes, but the pass-priority behavior and outbox transport routing are operationally significant even though they are "fixed" rather than "changed".How can I help you explore Laravel packages today?