CQRS in PHP — Stop Mixing Reads and Writes
Why your PHP read methods are silently writing data — and how Command/Query separation with EventBus, CommandBus, and QueryBus fixes it. Examples for Symfony, Laravel, and standalone.
Updated on 2026-05-14
You’ve debugged this bug. Someone calls $products->findById($id) from a controller — innocent, right? Then a customer reports their view count jumped by 200 overnight. Turns out findById also increments a counter “for analytics,” and a cache warmer is calling it in a loop. The function looks like a read. It isn’t. That’s the bug CQRS prevents.
The short version. CQRS — Command Query Responsibility Segregation — splits your code into two buckets: commands change state, queries read state. A query that touches the database for writes is a bug. A command that pretends to be idempotent is a bug. Once the boundary is enforced, you can read freely without worrying about side effects, and you can route commands and queries through different infrastructure (caches, replicas, async channels) without rewriting code. Ecotone gives you a CommandBus and QueryBus behind PHP attributes — no glue, no factories, no conventions to memorise.
The split — commands change, queries don’t
A command is a request to change state — ChangeUserEmail, PlaceOrder, CancelSubscription. It can fail; it can be rejected. It returns void or a tiny acknowledgement. Crucially, it never returns the data you’re modifying — that’s a separate concern.
A query reads state and returns it. Queries must have no observable side effects. No cache writes, no view counters, no last-accessed timestamps. If a “read” updates a row, it’s a command in a wig.
That single agreement — queries are pure reads — is the entire point. It’s why CQRS makes systems reasonable in a way CRUD doesn’t.
Defining a command
A command is a plain PHP class describing the change you want:
final class ChangeUserEmail
{
public function __construct(
public readonly string $userId,
public readonly string $newEmail,
) {}
}The handler is a method marked with #[CommandHandler] — Ecotone routes commands to handlers by the first parameter’s type:
final class UserService
{
#[CommandHandler]
public function changeEmail(ChangeUserEmail $command, Users $users): void
{
$user = $users->byId($command->userId);
$user->changeEmail($command->newEmail);
$users->save($user);
}
}Send the command from a controller through the auto-registered CommandBus:
final class UserController
{
public function __construct(private CommandBus $bus) {}
public function changeEmail(string $userId, Request $request): Response
{
$this->bus->send(new ChangeUserEmail(
$userId,
$request->get("email"),
));
return new Response(204);
}
}The controller doesn’t know which class handles the command. It just sends it. Refactoring the handler — moving it, renaming it, splitting it — doesn’t touch the controller.
Defining a query
Queries follow the same shape but go through a separate QueryBus:
final class GetUserShippingAddress
{
public function __construct(public readonly string $userId) {}
}
final class UserQueries
{
#[QueryHandler]
public function shippingAddress(
GetUserShippingAddress $query,
Connection $db,
): ShippingAddress {
return ShippingAddress::fromRow(
$db->fetchOne("SELECT * FROM addresses WHERE user_id = ?", [$query->userId])
);
}
}$address = $queryBus->send(new GetUserShippingAddress($userId));Two buses, two intents. Once the codebase is structured this way, finding “everything that can change a user” is one grep for #[CommandHandler]; finding “everything that reads a user” is one grep for #[QueryHandler]. That’s the productivity win nobody talks about.
The modern idiom — handlers on the aggregate
For domain models, you don’t need a separate service class for command handlers — put the handler on the aggregate itself:
#[Aggregate]
final class User
{
public function __construct(
#[Identifier] private string $id,
private string $email,
) {}
#[CommandHandler]
public function changeEmail(ChangeUserEmail $command): void
{
$this->email = $command->newEmail;
}
}#[Identifier], applies the change, and persists — no separate UserService needed.This is where CQRS stops being a pattern and starts being how the code looks. The behaviour and the state live in the same class; the bus orchestration lives in the framework.
Symfony, Laravel, or standalone — same code
The handlers, commands, and queries above have no framework dependency. Same domain code, three install paths:
# Symfony — auto-registers via the bundle
composer require ecotone/symfony-bundle
# Laravel — auto-discovered via the service provider
composer require ecotone/laravel
# Standalone / lambda / worker / CLI tools — any PSR-11 container
composer require ecotone/lite-applicationUserService, UserQueries, and User aggregate run identically on either stack.Common questions
Do I need event sourcing to use CQRS?
No. CQRS is the read/write split; event sourcing is one way to store the write side. You can do CQRS with Doctrine, Eloquent, or any ORM. Ecotone supports both state-stored and event-sourced aggregates — pick what fits.
What about CQRS with separate read and write databases?
CQRS doesn’t require physically separate stores — the segregation is logical first. If you do split (write to PostgreSQL, read from a denormalised projection in Redis or Elasticsearch), Ecotone’s #[Projection] handlers keep the read model up to date from domain events. See projection docs.
Is this a replacement for Symfony Messenger or Laravel Bus?
No. Messenger and Laravel Bus are message dispatchers; Ecotone’s CommandBus/QueryBus sit on top, adding routing-by-attribute, handler-on-aggregate, async with per-handler isolation, and projections. Keep what you have — add what’s missing.
Wrapping up
CQRS sounds enterprisey but it’s really just one rule: don’t pretend a write is a read. Once that rule is structurally enforced, half the bugs you fix in legacy PHP services stop happening — including the one where a “harmless” read silently mutates a counter. Companion read: Event Handling in PHP — From Tangled Code to Clean Flows covers the natural next step, publishing events from your command handlers.
The full surface lives in the Ecotone command handling docs.
Dariusz Gafka is a Software Architect and author of the Ecotone Framework. He writes about event sourcing, CQRS, and message-driven PHP architecture.