CQRS in PHP in Practice: Separating Commands and Queries
AI generated
8.4
PHP, Resilience, Architecture Patterns
CQRS in PHP in Practice
cleanly separating commands and queries

A single model that both enforces complex business rules on writes and delivers fast, denormalized views for the UI almost always forces lazy compromises on both sides. CQRS resolves that tension by consistently separating commands that change state from queries that only read, each with its own model tailored to its purpose.

15 min read CommandBus, QueryBus Read and Write Model

1. Why a single model for reading and writing often does not fit

An order with complex business logic has to enforce numerous invariants on the write side: checking stock levels, applying discount rules, keeping payment status consistent. That same order also has to appear in the UI as a compact list with customer names, totals, status, and various filters spanning several tables, which places completely different demands on the shape of the data.

A single entity or repository meant to serve both jobs at once either turns into an overloaded god object that mixes read and write logic messily, or the UI queries run inefficiently through the domain-oriented write model, hitting n-plus-one problems and unnecessarily complex joins just to get simple display data.

2. Command Query Separation as the originating principle

CQRS traces back to the CQS principle coined by Bertrand Meyer, which applies at the method level: a method either changes state or returns a value, never both at once. CQRS lifts this principle from a single method up to the architecture level of an entire bounded context.

Commands such as PlaceOrderCommand describe an intent to change state and return no business data themselves, at most a generated id or a success acknowledgement. Queries such as OpenOrdersForCustomerQuery exclusively read instead, with zero side effects, and return data shaped however the calling code actually needs it.

3. A minimal CommandBus implementation

A CommandBus routes a command object to its registered handler based on its class name, without the calling code needing to know the concrete handler class at all. That decouples the place expressing an intent from the place actually carrying it out.

The matching handler encapsulates the actual write logic: it loads an aggregate if needed, calls an appropriate command method on it, and saves the result through a repository, without preparing any return data for the UI itself.


<?php

declare(strict_types=1);

namespace App\Cqrs;

interface Command
{
}

interface CommandHandler
{
    public function __invoke(Command $command): void;
}

final class CommandBus
{
    /** @var array<class-string, CommandHandler> */
    private array $handlers = [];

    public function register(string $commandClass, CommandHandler $handler): void
    {
        $this->handlers[$commandClass] = $handler;
    }

    public function dispatch(Command $command): void
    {
        $handler = $this->handlers[$command::class]
            ?? throw new \RuntimeException('No handler registered for ' . $command::class);

        $handler($command);
    }
}

final readonly class PlaceOrderCommand implements Command
{
    public function __construct(
        public string $customerId,
        public array $items,
    ) {
    }
}

final class PlaceOrderHandler implements CommandHandler
{
    public function __construct(private readonly OrderRepository $orders)
    {
    }

    public function __invoke(Command $command): void
    {
        /** @var PlaceOrderCommand $command */
        $order = Order::place($command->customerId, $command->items);
        $this->orders->save($order);
    }
}

4. A minimal QueryBus implementation

A QueryBus works almost identically to the command bus structurally, with the key difference that every handler returns a value. The method is therefore deliberately named ask() rather than dispatch(), making the semantic difference between a state change and a pure read visible in the code itself.

Query handlers deliberately read directly from a table or database view optimized for reads, instead of going through the domain aggregate and its business rules, since those rules do not matter for pure reading anyway.


<?php

declare(strict_types=1);

namespace App\Cqrs;

interface Query
{
}

interface QueryHandler
{
    public function __invoke(Query $query): mixed;
}

final class QueryBus
{
    /** @var array<class-string, QueryHandler> */
    private array $handlers = [];

    public function register(string $queryClass, QueryHandler $handler): void
    {
        $this->handlers[$queryClass] = $handler;
    }

    public function ask(Query $query): mixed
    {
        $handler = $this->handlers[$query::class]
            ?? throw new \RuntimeException('No handler registered for ' . $query::class);

        return $handler($query);
    }
}

final readonly class OpenOrdersForCustomerQuery implements Query
{
    public function __construct(public string $customerId)
    {
    }
}

final class OpenOrdersForCustomerHandler implements QueryHandler
{
    public function __construct(private readonly \PDO $readConnection)
    {
    }

    public function __invoke(Query $query): array
    {
        /** @var OpenOrdersForCustomerQuery $query */
        // Reads directly from a read-optimized view, completely bypassing
        // the Order aggregate and its business rules.
        $stmt = $this->readConnection->prepare(
            'SELECT order_id, status, total_cents, placed_at FROM order_list_view
             WHERE customer_id = :customer_id AND status != :status'
        );
        $stmt->execute(['customer_id' => $query->customerId, 'status' => 'closed']);

        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
    }
}

5. Separate read and write models in practice

On the write side sits a full-fledged Order aggregate with all its business rules, loaded through a repository. A command handler calls an appropriate method on that aggregate and saves it back through the same repository afterwards, unchanged.

On the read side sits a slim OrderListItemDto instead, populated directly via SQL from a table or view optimized for reading, completely bypassing the domain aggregate, and equipped with exactly the fields the UI actually needs, no more and no less.

6. How CQRS relates to, and combines with, event sourcing

CQRS is independent of event sourcing and works just as well with classic relational persistence on the write side: a command handler can write with a plain UPDATE into the same table that gets read from later, or even directly.

Combining both patterns is nonetheless a natural fit: event sourcing then supplies the write model in the form of an aggregate reconstructed from events, while the same event stream is used asynchronously to build any number of read models as projections, as described in the separate article on event sourcing. CQRS is the architectural separation itself, event sourcing is one possible, but by no means required, implementation technique for the write side.

7. Asynchronous read model updates and eventual consistency

Once the read and write models are separated and the read model is updated asynchronously, for example via a message sent after a successful command, a short delay inevitably appears. A user who just placed an order might briefly still see the old state on the read side, until the projection catches up.

Common strategies to handle this include a read-your-writes pattern, where the write model is briefly read from instead of the read model right after a user's own command, a UI hint such as updating, or simply and deliberately accepting a brief delay where that is not a business problem.

8. When CQRS is simply overkill

For simple, CRUD-heavy applications without sharply diverging read and write needs and without real business complexity, introducing a CommandBus and QueryBus mostly adds ceremony without delivering a noticeable benefit. A direct repository call is faster to build and much easier for new team members to understand in that case.

A pragmatic middle ground is to apply CQRS deliberately only in the handful of genuinely complex bounded contexts within an application, while simple CRUD areas of the same application keep relying on a single, direct model. That way the extra effort stays confined to the places where it actually pays off from a business perspective.

9. Common mistakes when introducing CQRS

A widespread mistake is introducing CQRS everywhere in an application out of pure dogma, instead of applying it deliberately where a real business need exists. Just as problematic are query handlers that accidentally mutate state, for example through incidental logging with a side effect or a counter increment on every read, which clearly contradicts the core principle.

Another common mistake is assuming CQRS automatically requires event sourcing or a physically separate database, neither of which is true. And finally, teams often underestimate the complexity introduced by eventual consistency once the read model is actually updated asynchronously, which without a deliberate strategy can lead to confusing, seemingly inconsistent state in the UI.

Criterion Single model (CRUD) CQRS
Business complexity on writes Low to medium High, many invariants
Read requirements Similar to the write model Diverges sharply, aggregated, denormalized
Team entry barrier Low Higher, two models plus infrastructure
Consistency requirement Immediate consistency expected Eventual consistency often acceptable
Typical use case Admin backends, simple CRUD modules Order processing, accounting, complex workflows

Mironsoft

PHP modernization, code quality, and legacy refactoring

Grown PHP code nobody wants to touch anymore?

We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.

Legacy Refactoring

Modernize grown PHP code in a structured, low-risk way.

Establishing Code Quality

Anchor PHPStan, coding standards, and CI checks sustainably in the team.

Version Upgrades

Plan and execute PHP major version upgrades safely, without downtime.

10. Summary

CQRS in PHP: The Essentials

Core principle

Commands change state with no return value, queries read with no side effects, each with its own model.

Infrastructure

A CommandBus and a QueryBus route requests to the right handler based on their type.

Scope

CQRS is independent of event sourcing, but the two patterns combine well when it makes sense.

Adoption decision

Use it only where there is real business complexity or sharply diverging read requirements.

11. FAQ: CQRS in PHP: The Essentials

1What does CQRS stand for?
Command Query Responsibility Segregation, the deliberate separation of commands that change state and queries that only read into their own models, instead of one shared model for both.
2What is the difference between CQS and CQRS?
Command Query Separation is a method-level principle from object oriented programming. CQRS lifts that idea to the architecture level and separates entire models instead of just individual methods.
3Do I need two separate databases for CQRS?
No. CQRS initially only describes the logical separation of read and write models in the code. Both can, especially at first, run on the very same database without any problem.
4Is CQRS the same as event sourcing?
No, they are two independent patterns. CQRS works fine with classic relational persistence, though event sourcing combines well as a write model alongside CQRS.
5What does a CommandBus actually do?
It accepts a command object, determines the responsible handler based on its type, and invokes it, without the calling code needing to know the concrete handler class.
6Why should a query handler never change state?
Because that contradicts the core principle of CQRS and causes a seemingly harmless read to trigger unexpected side effects, which makes traceability and testability noticeably harder.
7What does eventual consistency mean in a CQRS context?
When the read model is updated asynchronously from the write model, a change that was just written may briefly not yet be visible on the read side until the update catches up.
8When is CQRS the wrong choice?
For simple, CRUD-heavy areas without real business complexity and without sharply diverging read requirements, where a single, direct model is faster to build and easier to understand.
9Can I use CQRS for only part of an application?
Yes, and that is actually recommended. CQRS can be applied deliberately to individual, business-complex bounded contexts while other, simpler areas of the same application keep a single model.
10How do I test command and query handlers?
Command handlers are verified through the produced domain event or the changed aggregate state, query handlers through the returned data given a known, prepared data set, each independently of the other.