Symfony CQRS Pattern: Cleanly Separating Commands and Queries
AI generated
SF
{ }
Symfony · CQRS · Command Bus · Query Bus · DDD
Symfony CQRS Pattern:
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.

18 min read CQRS · Command Bus · Query Bus · Read Models · Handlers Symfony 6.x / 7.x · PHP 8.2+ · Symfony Messenger

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.

11. FAQ: Symfony CQRS Pattern

1What is CQRS?
Command Query Responsibility Segregation: separating write operations (commands) and read operations (queries) into separate classes, handlers and data models.
2Is a framework needed for CQRS?
No. Symfony Messenger with command.bus and query.bus as separate instances is fully sufficient. No external libraries such as Tactician or Broadway are needed.
3Commands without a return value?
CQRS principle: strictly separate writing and reading. Generate a UUID in advance and pass it in the command instead of returning it from the handler. Load it afterward via query.
4What is a read model?
A flat DTO with exactly the view's fields. Produced from raw SQL queries without Doctrine ORM, faster, no lazy-loading overhead, no unnecessary join.
5How to configure Command Bus and Query Bus?
In messenger.yaml under buses: define command.bus and query.bus. Route commands async, queries get no transport (always sync). Assign handlers with #[AsMessageHandler(bus: 'query.bus')].
6Reading a query handler's return value?
$envelope->last(HandledStamp::class)->getResult(). For readable code, create a query bus wrapper with an ask() method that encapsulates this detail.
7When is CQRS not worth it?
For small CRUD projects, small teams and low domain complexity. A traditional service layer is then simpler and more pragmatic.
8Entities in query handlers?
Possible but often suboptimal. Raw SQL with DBAL and read-model DTOs are faster without ORM overhead and lazy-loading proxies for read operations.
9Testing command handlers?
Unit: instantiate the handler, mock deps, create a command, call __invoke(), check repository mock assertions. Integration: load from container, dispatch a real command, check DB state.
10CQRS vs. Event Sourcing?
CQRS separates reading and writing. Event Sourcing stores all state changes as events. They complement each other well but are independently applicable, CQRS without Event Sourcing is common and sensible.