Symfony CQRS: Separate Command and Query Bus Configuration with Messenger
AI generated
SF
{ }
Symfony · CQRS · Messenger · Architecture
Symfony CQRS
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.

15 min read Symfony Messenger CQRS · command.bus · query.bus

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

11. FAQ: CQRS With Separate Buses: Key Facts

1Why isn't a single Messenger bus enough for CQRS?
Because commands and queries have different middleware requirements. A shared bus either forces both message types through the same middleware or requires special-casing inside every middleware component.
2How are two buses configured in Symfony?
Through the buses key in messenger.yaml, where each bus gets its own name and its own middleware list. The common names are command.bus and query.bus.
3Does the query bus really not need transaction middleware?
In most cases, no, since queries by definition don't mutate state. Transaction wrapping would only create unnecessary database locks without offering any consistency benefit.
4How does a command get automatically routed to the right bus?
Through the routing mapping in messenger.yaml, which maps message classes or marker interfaces like CommandInterface to a specific bus, so the dispatching code itself doesn't need to distinguish between buses.
5Can commands run asynchronously while queries stay synchronous?
Yes. Individual commands can be routed to an asynchronous transport through command.bus's routing configuration, while query.bus is usually left without any transport at all, meaning fully synchronous.
6What happens when a command handler fails?
The TransactionMiddleware catches the exception, rolls back the database transaction, and the original exception is unwrapped from the HandlerFailedException and handled appropriately.
7How is validation wired in for commands?
Through Symfony Messenger's built-in validation middleware, registered on command.bus, which checks commands against their validator constraints before the handler gets called.
8Is splitting into two buses worth it for small projects too?
For very small projects with only a handful of handlers, the extra setup is often not justified. Once several middleware components with different requirements for commands and queries appear, though, the separation pays off quickly.
9Can query handlers be cached?
Yes, through a dedicated caching middleware on query.bus that caches results of frequently repeated queries for a short window, without affecting the command side at all.
10Does splitting the buses make testing harder?
Quite the opposite, it makes testing easier. Tests can swap out just one bus with an in-memory transport to check in isolation whether a specific command or query was dispatched.