Cleanly Separating Commands and Queries
Controllers that write, read, transform and return data all at once are the normal state in many Symfony projects. CQRS consistently separates these responsibilities: commands change state, queries read data. The result is more maintainable, testable and scalable code.
Table of Contents
- 1. CQRS Basics: What Is Command Query Responsibility Segregation?
- 2. Building a Command Bus with Symfony Messenger
- 3. Commands: Type-Safe Write Operations
- 4. Query Bus: Always Synchronous, Always a Return Value
- 5. Read Models: Optimized Read Structures
- 6. Handlers: Separating Command and Query Logic
- 7. CQRS in the Controller: Thin Controller, Fat Domain
- 8. Testing CQRS Code: Commands and Queries in Isolation
- 9. CQRS vs. Traditional Service Layer Compared
- 10. Summary
- 11. FAQ
1. CQRS Basics: What Is Command Query Responsibility Segregation?
CQRS, Command Query Responsibility Segregation, is an architecture pattern that descends from Bertrand Meyer's Command-Query-Separation principle. The core of the principle is simple: a function should either perform an operation and change state (command) or return data without changing state (query), but never both at once. In practice this means methods like getUserAndMarkAsLastVisited() violate the principle because they combine reading and writing in a single operation. CQRS enforces this separation at the architecture level: two separate models, two separate handler classes, two separate data paths.
The benefit of CQRS is not the separation itself but the possibilities it opens up. The write model can be optimized for consistency and domain correctness, with aggregates, domain events and strict validation. The read model can be optimized for performance, with denormalized data, read models, caching and direct SQL queries without entity overhead. In a Symfony project with Symfony Messenger as the bus infrastructure, a clean CQRS implementation emerges without external libraries: commands go through the Command Bus, queries through the Query Bus, both with dedicated handlers.
2. Building a Command Bus with Symfony Messenger
Symfony Messenger is configured as a CQRS command bus by defining separate bus instances for commands and queries. The configuration in config/packages/messenger.yaml defines two buses: command.bus and query.bus. The Command Bus has the HandleMessageMiddleware middleware with the setting allow_no_handlers: false, meaning every command must have exactly one handler, otherwise it is a configuration error. The Query Bus has the same setting and returns the handler's return value, which is an important detail with Symfony Messenger: $messageBus->dispatch() returns an Envelope object, from which you can read the handler's result using HandledStamp::class.
The separation into two bus instances enforces the CQRS rules at the service level. A controller that dispatches a query over the Command Bus gets a configuration error because queries have no command handlers. Symfony's DI container makes it possible to inject the correct bus via service aliases: MessageBusInterface $commandBus and MessageBusInterface $queryBus are resolved to the correct bus instances through corresponding binding configuration. This enables type-safe injection without manual service IDs in every controller.
# config/packages/messenger.yaml: CQRS, separate Command Bus and Query Bus
framework:
messenger:
# Command Bus: exactly one handler per command, can be async
buses:
command.bus:
middleware:
- doctrine_transaction # wrap in DB transaction
- validation # validate command before handler
# Query Bus: always sync, always returns a value
query.bus:
default_middleware:
enabled: true
allow_no_handlers: false # query without handler is a bug
# Event Bus: 0..n handlers, async OK
event.bus:
default_middleware:
allow_no_handlers: true
# Only commands can be routed async, queries must be sync
routing:
App\Command\*: async
App\Query\*: ~ # no transport = always synchronous
App\Domain\Event\*: async
# services.yaml: type-safe bus injection via named binding
# services:
# _defaults:
# bind:
# $commandBus: '@command.bus'
# $queryBus: '@query.bus'
3. Commands: Type-Safe Write Operations
A CQRS command is a value object that expresses the intent of a state change. The name of a command is a statement in the imperative: RegisterUserCommand, PublishArticleCommand, CancelOrderCommand. Commands carry all the data needed for the operation, no database entities, no services, only scalars and value objects. They are immutable and are never modified after creation. Constructor property promotion in PHP 8.x makes commands compact: every property is readonly, the constructor declares everything in a single line.
Commands must not return a value. That is the CQRS rule for commands: either the operation succeeds and a domain event is fired, or it fails with an exception. No controller should wait for a result after dispatching a command. If the controller needs the generated ID for a redirect URL after saving, the ID is already passed in the command (for example a UUID that the client or the controller generates in advance), or it is fetched afterward via a separate query. This convention sounds restrictive but enforces a cleaner architecture: commands are fire and forget, the effect is an event.
<?php
declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Command: register a new user account.
* Immutable value object: all data is set at construction, never mutated.
*/
final readonly class RegisterUserCommand
{
public function __construct(
// Pre-assigned UUID, no need to return generated ID from handler
public readonly Uuid $userId,
#[Assert\NotBlank]
#[Assert\Email]
public readonly string $email,
#[Assert\NotBlank]
#[Assert\Length(min: 8, max: 72)]
public readonly string $plainPassword,
#[Assert\Choice(['de', 'en', 'fr'])]
public readonly string $locale = 'de',
) {}
}
// ---------------------------------------------------------------------------
// In the controller: generate UUID before dispatch, use it for redirect
namespace App\Controller;
use App\Command\RegisterUserCommand;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Uid\Uuid;
final class RegistrationController
{
public function __construct(
private readonly MessageBusInterface $commandBus,
private readonly RouterInterface $router,
) {}
public function register(/* ... FormData ... */): RedirectResponse
{
// Generate ID before dispatch, no need to read it back from handler
$userId = Uuid::v4();
$this->commandBus->dispatch(new RegisterUserCommand(
userId: $userId,
email: 'user@example.com',
plainPassword: 'securepassword',
));
// Redirect immediately, command succeeded (no exception = success)
return new RedirectResponse($this->router->generate('app_user_show', [
'id' => $userId->toRfc4122(),
]));
}
}
4. Query Bus: Always Synchronous, Always a Return Value
Queries in CQRS are the counterpart to commands: they never change state, always return a value and always run synchronously. A query is likewise a value object with a descriptive name: GetProductByIdQuery, FindProductsByCategoryQuery, GetOrderSummaryQuery. The name describes what is being queried, not how. The query carries all filter parameters, IDs, search terms, sorting, pagination, as immutable properties.
Reading the return value from Symfony Messenger is a concrete detail that often causes confusion during a CQRS implementation. $bus->dispatch($query) returns an Envelope object, not the handler's return value directly. You read the value with $envelope->last(HandledStamp::class)->getResult(). A helper method or a query bus wrapper elegantly encapsulates this detail: $result = $this->ask($query) instead of $this->queryBus->dispatch($query)->last(HandledStamp::class)->getResult(). This wrapper makes controllers more readable and tests simpler, because you only need to mock the wrapper, not the entire Messenger stack.
5. Read Models: Optimized Read Structures
The greatest performance potential of CQRS lies in the read models. Instead of loading Doctrine entities with all their relations, lazy-loading proxies and mapping overhead, a query handler creates a flat DTO (data transfer object) directly from a raw SQL statement. A ProductListItemReadModel contains exactly the fields a product list displays: name, price, thumbnail URL, available quantity, average rating. No unnecessary joins, no unloaded relations, no serialization overhead for fields that are never displayed.
In Symfony, DBAL is the tool of choice for read-model queries: direct SQL queries with $this->connection->fetchAllAssociative($sql, $params) return associative arrays that are mapped into read-model objects. This avoids the entire Doctrine ORM overhead for read operations. CQRS allows this optimization without losing domain consistency: the write side continues to use Doctrine entities with full domain logic, while the read side uses optimized raw SQL queries tailored to the specific view requirements. A read model can even come from a denormalized view or an Elasticsearch index, the query handler fully abstracts the data source.
<?php
declare(strict_types=1);
namespace App\ReadModel;
/**
* Read model for product list: flat DTO optimized for list views.
* Contains exactly what the list template needs, nothing more.
*/
final readonly class ProductListItemReadModel
{
public function __construct(
public readonly string $id,
public readonly string $name,
public readonly string $slug,
public readonly string $price,
public readonly string $currency,
public readonly string $thumbnailUrl,
public readonly int $stockQuantity,
public readonly float $averageRating,
public readonly int $reviewCount,
) {}
/** Factory from raw DBAL associative array result */
public static function fromArray(array $row): self
{
return new self(
id: $row['id'],
name: $row['name'],
slug: $row['slug'],
price: $row['price'],
currency: $row['currency'],
thumbnailUrl: $row['thumbnail_url'] ?? '/images/placeholder.svg',
stockQuantity: (int) $row['stock_quantity'],
averageRating: (float) $row['average_rating'],
reviewCount: (int) $row['review_count'],
);
}
}
// ---------------------------------------------------------------------------
// Query Handler using raw DBAL, no Doctrine ORM overhead for reads
namespace App\QueryHandler;
use App\Query\FindProductsByCategoryQuery;
use App\ReadModel\ProductListItemReadModel;
use Doctrine\DBAL\Connection;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class FindProductsByCategoryHandler
{
public function __construct(
private Connection $connection,
) {}
/** @return ProductListItemReadModel[] */
public function __invoke(FindProductsByCategoryQuery $query): array
{
// Raw SQL, optimized for the list view, no unnecessary joins
$rows = $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
p.id, p.name, p.slug, p.price, p.currency,
p.thumbnail_url,
COALESCE(p.stock_quantity, 0) AS stock_quantity,
COALESCE(AVG(r.rating), 0) AS average_rating,
COUNT(r.id) AS review_count
FROM product p
LEFT JOIN product_review r ON r.product_id = p.id
WHERE p.category_id = :categoryId
AND p.is_published = 1
GROUP BY p.id
ORDER BY p.sort_order ASC, p.name ASC
LIMIT :limit OFFSET :offset
SQL,
[
'categoryId' => $query->categoryId,
'limit' => $query->limit,
'offset' => ($query->page - 1) * $query->limit,
],
);
return array_map(ProductListItemReadModel::fromArray(...), $rows);
}
}
6. Handlers: Separating Command and Query Logic
Command handlers in CQRS are the only classes allowed to make state changes. A command handler loads an aggregate from the repository, calls a domain method, persists the aggregate and dispatches a domain event. It returns nothing, the return type is void. The only communication to the outside is via exception (failure case) or domain event (success case). This rule enforces consistent decoupling: controllers and other handlers must learn about state changes via events, not via return values.
Query handlers in CQRS handle read access exclusively. They never load Doctrine entities for later saving, never fire domain events and never modify state. The return type is always a read model, a DTO or a scalar, never a Doctrine entity that could accidentally be modified and saved. Symfony's #[AsMessageHandler(bus: 'query.bus')] attribute ensures that query handlers are only registered on the Query Bus and never accidentally end up as command handlers. This separation at the service level is the strongest guarantee CQRS can offer in Symfony.
7. CQRS in the Controller: Thin Controller, Fat Domain
Controllers in a CQRS Symfony project are radically thin. They accept the HTTP request, build a command or a query from form data or the request body, dispatch it over the corresponding bus and return an HTTP response. No business logic, no direct database access, no service calls beyond bus dispatch. A controller that combines a write operation and a read operation, for example loading and displaying the saved data after a form submit, first dispatches a command, then a query: two explicit, separate operations that make the intent clear.
This approach turns controllers into pure adapter classes between HTTP and the domain model. The consequence: controllers are trivial to test because they contain no logic. Business logic lives in command handlers, which can be tested independently of HTTP. Query logic lives in query handlers, which are covered by simple unit tests. The CQRS architecture draws a clear boundary between infrastructure (controller, HTTP), application (command and query handlers) and domain (aggregates, events, value objects), the classic hexagonal architecture pattern at the Symfony implementation level.
8. Testing CQRS Code: Commands and Queries in Isolation
The strongest testability argument for CQRS: commands and queries are simple value objects, no interfaces to implement, no base classes to extend. Their tests are straightforward: create a command instance, instantiate the command handler with mocked dependencies, call the handler and check whether the repository was called with the correct aggregate. Query handler tests check whether the DBAL connection receives the correct SQL query with the correct parameters, and whether the result is correctly converted into read-model objects.
Integration tests for CQRS handlers use Symfony's KernelTestCase base class: the handler is loaded from the container, a real command is dispatched and the effect in the test database is checked. Important: after every integration test the database must be reset to its initial state. The dama/doctrine-test-bundle bundle does this automatically with a transaction rollback after every test, no explicit truncating, no fixture reload, no slow test teardown. CQRS handlers are inherently testable because each one has exactly one responsibility.
| Aspect | Traditional Service | CQRS with Symfony Messenger | CQRS Advantage |
|---|---|---|---|
| Responsibility | Reading + writing mixed | Commands vs. queries separated | Single responsibility per handler |
| Read performance | ORM entities for everything | Raw SQL with read models | No ORM overhead for reads |
| Scaling | Read/write DB shared | Read replica possible | Separate scaling of R and W |
| Testability | Service with many deps | Handler with one dep | Isolated, precise test |
| Async | Implement manually | Configurable via routing | No handler code needed |
9. CQRS vs. Traditional Service Layer Compared
CQRS is not a universally superior approach, it is an architecture decision with consequences in both directions. The traditional service layer is easier to understand and often sufficient for small projects: a UserService with create(), findById(), update() and delete() methods. CQRS increases initial complexity through more classes, more layers and more explicit conventions. This overhead only pays off from a certain project size onward, or when specific scaling requirements exist.
The decision for CQRS in a Symfony project is worthwhile when: the team is larger than three to four developers and conventions need to be enforced rather than relying on understanding alone. Read and write load are unevenly distributed and the read side needs separate optimizations. The domain has complex invariants that need to be protected with aggregates. Asynchronous processing of write operations is planned. For classic CRUD applications without complex domain logic, the traditional service layer with clear method naming is often the more pragmatic choice.
Mironsoft
Symfony architecture, CQRS implementation and domain-driven design
Building a CQRS architecture for your Symfony project?
We design and implement CQRS architectures in Symfony, from Command and Query Bus setup through read-model design to complete test suites for commands and queries in your project.
Architecture Review
Analysis of existing Symfony projects for CQRS potential and a refactoring path
CQRS Implementation
Command Bus, Query Bus, read models and handler structure for your domain model
Test Strategy
Unit and integration tests for commands and queries with dama/doctrine-test-bundle
10. Summary
CQRS in Symfony separates writing commands and reading queries at the architecture level. Symfony Messenger provides the necessary bus infrastructure as Command Bus and Query Bus without external libraries. Commands are immutable value objects that express the intent to change state, without a return value, with exactly one handler. Queries are likewise immutable value objects that always return a value and never change state. Read models optimize the read side with raw SQL and flat DTOs without ORM overhead. Handlers have exactly one responsibility and are testable in isolation.
The greatest gain of CQRS lies not in the technology but in the enforced discipline: every class has a clearly defined responsibility, every code path is either reading or writing, never both. This eases code reviews, simplifies tests and enables later optimizations, read replicas, caching, denormalized views, without changes to the write side. For Symfony projects with complex domain logic and medium to large teams, CQRS is an investment that pays off more and more as the codebase grows.
Symfony CQRS: The Essentials at a Glance
Separation of Responsibility
Commands change state, return nothing. Queries read data, change nothing. Two buses, two handler hierarchies, two data models.
Read Models for Performance
Raw SQL instead of ORM for read operations. Flat DTOs with exactly the fields the view needs. No lazy loading, no proxy overhead.
Symfony Messenger as Bus
Separate command.bus and query.bus instances in messenger.yaml. Commands can be async, queries are always sync with a return value.
Thin Controller
Controller dispatches a command or query, no business logic, no direct DB access. Business logic lives entirely in handlers.