separate command and query bus configuration with Messenger
CQRS separates reads from writes, but that separation stays half-hearted as long as both flow through the same Messenger bus with identical middleware. Two separate buses, command.bus and query.bus, each with its own middleware stack, make the separation not just conceptual but technically consistent, preventing query handlers from running inside unnecessary transactions or command handlers slipping through without validation.
Table of Contents
- 1. Why a shared bus for commands and queries falls short
- 2. Configuring two buses in messenger.yaml
- 3. Different middleware stacks: validation vs. no transaction wrapping
- 4. Keeping command and query handler registration separate
- 5. A command example with validation and a transaction
- 6. A query example without transaction overhead
- 7. Designing error handling separately per bus
- 8. Synchronous queries, partially asynchronous commands
- 9. Testability through the clear bus separation
- 10. Summary
- 11. FAQ
1. Why a shared bus for commands and queries falls short
The Symfony Messenger component ships with a single default bus that can dispatch both commands and queries. That works fine with only a handful of handlers, but once middleware enters the picture, the downside of this simplification shows up quickly. A transaction middleware that wraps every message in a database transaction makes sense for a write-side command, but adds needless overhead to a purely read-only query and, in the worst case, locks database resources for no benefit at all.
The same problem applies in reverse for validation: a command that creates a new customer should never run without validating its input data first, while a query that merely reads customer data doesn't need input validation in the classic sense, at most some parameter constraints. If both message types run through the same middleware stack, either every middleware has to distinguish between command and query itself, which needlessly complicates the code, or you accept unused or misplaced middleware calls.
2. Configuring two buses in messenger.yaml
Configuring two separate buses happens centrally in messenger.yaml under the buses key. Each bus gets its own name, here command.bus and query.bus, plus its own list of middleware that only runs for messages on that bus. Symfony automatically registers one service per bus name in the container, so handlers and calling code explicitly state which bus they want to use instead of relying on an implicit default bus.
It matters that commands and queries are also modeled as distinct message types, for example through a shared CommandInterface or QueryInterface, which can simply be marker interfaces with no methods. Routing can then automatically assign messages to the right bus based on their class or interface, so a caller doesn't have to manually decide which bus is meant on every dispatch() call.
# config/packages/messenger.yaml
framework:
messenger:
buses:
command.bus:
middleware:
- validation
- App\Messenger\Middleware\TransactionMiddleware
query.bus:
middleware:
- App\Messenger\Middleware\QueryLoggingMiddleware
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'App\Command\CommandInterface': command.bus
'App\Query\QueryInterface': query.bus
3. Different middleware stacks: validation vs. no transaction wrapping
For command.bus, a combination of validation middleware, which checks incoming commands against Symfony's Validator component, and a custom TransactionMiddleware that wraps the handler call in a Doctrine transaction and automatically rolls back on exception, makes a lot of sense. This guarantees a command either fully succeeds or leaves no change behind at all, which is a core consistency guarantee for write-side operations.
The query.bus deliberately skips both middleware components. A query only reads data and by definition never mutates state, so transaction wrapping offers no benefit and only requests additional database locks that can slow down concurrent reads unnecessarily. Instead, query.bus can carry a lean logging or caching middleware, for example one that caches frequently repeated queries for a short window.
4. Keeping command and query handler registration separate
Symfony auto-discovers message handlers by default based on the type hint in the __invoke method, or via the MessageHandlerInterface, regardless of which bus they should attach to. In most cases, routing through the respective marker interface is enough to unambiguously assign a handler to a bus, but in more complex setups with multiple buses handling the same message type, the assignment can also be made explicit via the AsMessageHandler attribute's bus parameter.
In practice, a consistent naming convention helps: command handler classes live under the App\Command\Handler namespace, query handler classes under App\Query\Handler. This clear separation in the directory tree makes it obvious at a glance which class belongs to which bus, and prevents a command handler from accidentally getting registered for a query, or the other way around.
5. A command example with validation and a transaction
A CreateOrderCommand carries all the data needed to create an order as immutable properties, ideally with Symfony Validator constraints annotated directly on those properties. When dispatched via command.bus, the validation middleware checks those constraints before the actual handler even gets called, so invalid commands never reach the business logic in the first place.
The corresponding CreateOrderHandler contains the actual order creation logic and is fully wrapped by the TransactionMiddleware. If any step fails, say a referenced product no longer exists, the entire transaction rolls back, leaving no inconsistent intermediate state in the database.
6. A query example without transaction overhead
A GetOrderDetailsQuery carries only the parameters needed for identification, for example an order ID, and gets dispatched through query.bus. The corresponding handler reads the data directly from a read model or repository and returns a DTO, with no transaction or validation middleware in between at all. This makes the read path measurably faster, since every skipped middleware step is also skipped execution time.
The benefit of the separation shows especially clearly with read models optimized for display and not necessarily identical to the write model: a query can read directly against a denormalized view or a dedicated projection, while commands keep operating against the normalized write model with full transactional protection.
7. Designing error handling separately per bus
Since Messenger wraps every handler call in a HandlerFailedException, it's worth having a dedicated exception listener or middleware per bus that unwraps that exception back to the original one and handles it appropriately. For command.bus that usually means turning validation errors into a structured HTTP 400 response, while query.bus typically calls for simple not-found handling when the requested data doesn't exist.
This separated error handling matches the different semantics of both bus types: a failed command usually signals a problem with the input or the current system state, while a failed query often simply means the requested resource wasn't found, which isn't really an error at all but a regular, expected case.
8. Synchronous queries, partially asynchronous commands
Queries are almost always executed synchronously, because a user typically expects an immediate result and asynchronous processing rarely benefits a pure read operation. For that reason, query.bus usually has no transport configured at all, so messages are always handled synchronously in the same process.
For command.bus, on the other hand, it can make sense to route certain commands through an asynchronous transport, for example a SendWelcomeEmailCommand that doesn't need to be part of the actual transaction and shouldn't extend the response time of the main request. The routing mapping in messenger.yaml lets you assign individual command classes to an asynchronous transport like async, while others stay synchronous and transactional.
9. Testability through the clear bus separation
Splitting into two buses also makes testing considerably easier, since functional tests can swap out just command.bus or just query.bus with an in-memory transport to verify that a specific command was actually dispatched, without touching the other bus at all. Handler tests in turn benefit from command handlers and query handlers being clearly separated by naming convention and directory structure, making them straightforward to cover independently with focused unit tests.
In practice, teams that consistently keep both buses separate tend to accidentally place business logic inside query handlers far less often, because the absence of transaction middleware and the lack of write access in the read model make that pattern awkward from the start. The architecture enforces, at a technical level, a discipline that convention or code review alone often struggles to maintain consistently.
| Aspect | command.bus | query.bus |
|---|---|---|
| Purpose | mutate state | read state |
| Validation middleware | active | not needed |
| Transaction middleware | active, with rollback | not wired in |
| Execution | sync or async | usually synchronous |
| Typical return value | void or ID | DTO / read model |
Mironsoft
Symfony architecture, clean domain logic, and legacy modernization
Symfony applications that stay maintainable two years down the line?
We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.
Architecture Review
Checking bundle structure, dependency injection, and service abstractions for maintainability.
Legacy Modernization
Incrementally migrating outdated Symfony versions without a full rewrite.
Testing and Quality Assurance
Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.
10. Summary
CQRS With Separate Buses: Key Facts
Separation
two independent Messenger buses instead of one shared bus
Command middleware
validation plus transaction with automatic rollback
Query middleware
lean, no transaction wrapping, optionally with caching
Benefit
matching middleware per message type instead of a one-size-fits-all stack