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.
Table of Contents
- 1. Why a single model for reading and writing often does not fit
- 2. Command Query Separation as the originating principle
- 3. A minimal CommandBus implementation
- 4. A minimal QueryBus implementation
- 5. Separate read and write models in practice
- 6. How CQRS relates to, and combines with, event sourcing
- 7. Asynchronous read model updates and eventual consistency
- 8. When CQRS is simply overkill
- 9. Common mistakes when introducing CQRS
- 10. Summary
- 11. FAQ
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.