Implementing CQRS with Symfony Messenger: A Practical Guide
CQRS is one of the most powerful — and most misunderstood — patterns in the PHP ecosystem. Many think it requires Event Sourcing, complex infrastructure, and 200 classes for a CRUD. Wrong. With Symfony Messenger, you can implement pragmatic CQRS in a few hours.
CQRS in 5 minutes
CQRS separates your application into two sides:
- Commands: modify state (create, update, delete). Return void or an identifier.
- Queries: read state. Return data. Never modify anything.
Each side has its own bus, its own handlers, and potentially its own data models. The separation can be logical (same database) or physical (separate databases).
Why is this useful? Because read and write needs almost always diverge. Reads want denormalized, fast data. Writes want validation, business invariants, and consistency.
To understand how CQRS fits into a broader architectural strategy, see my article on software architecture for modern web applications.
Configuring Symfony Messenger for CQRS
The Command Bus
The Command Bus routes commands to their unique handler. One command = one handler. No negotiation.
# config/packages/messenger.yaml
framework:
messenger:
buses:
command.bus:
middleware:
- doctrine_transaction
query.bus: ~
// src/Command/PlaceOrderCommand.php
final readonly class PlaceOrderCommand
{
public function __construct(
public string $customerId,
public array $items,
) {}
}
The doctrine_transaction middleware automatically wraps each command in a transaction. If the handler throws an exception, everything is rolled back.
The Query Bus
The Query Bus is simpler: no transaction, no side effects. It returns data.
// src/Query/GetOrderSummaryQuery.php
final readonly class GetOrderSummaryQuery
{
public function __construct(
public string $orderId,
) {}
}
The Handlers
#[AsMessageHandler(bus: 'command.bus')]
final readonly class PlaceOrderHandler
{
public function __construct(
private OrderRepositoryInterface $orders,
private EventDispatcherInterface $dispatcher,
) {}
public function __invoke(PlaceOrderCommand $command): string
{
$order = Order::place($command->customerId, $command->items);
$this->orders->save($order);
$this->dispatcher->dispatch(new OrderPlacedEvent($order->id()));
return $order->id();
}
}
The handler is the only class that orchestrates logic. The domain (Order::place) contains business rules. The repository persists. The event notifies.
Concrete example: an e-commerce module
Let’s take a complete checkout flow:
Commands:
AddToCartCommand→ adds a product to cartPlaceOrderCommand→ validates and creates the orderProcessPaymentCommand→ launches payment (async via transport)
Queries:
GetCartQuery→ returns cart with calculated pricesGetOrderSummaryQuery→ returns post-order summaryListUserOrdersQuery→ paginated history
Commands go through the command.bus with Doctrine transaction. Queries go through the query.bus without transaction middleware. The ProcessPaymentCommand is routed to an async transport (RabbitMQ, Redis) to not block the user.
Testing your CQRS implementation
CQRS makes tests naturally simpler. Each handler is an isolated unit.
public function test_place_order_creates_order(): void
{
$repository = new InMemoryOrderRepository();
$dispatcher = new FakeEventDispatcher();
$handler = new PlaceOrderHandler($repository, $dispatcher);
$orderId = $handler(new PlaceOrderCommand('customer-1', ['item-a']));
$this->assertNotNull($repository->find($orderId));
$this->assertCount(1, $dispatcher->dispatched(OrderPlacedEvent::class));
}
No database, no Symfony kernel. The handler is tested with in-memory doubles. It’s fast, deterministic, and maintainable.
For more on testing strategies, especially E2E, see my article on E2E testing with Playwright.
When to use CQRS (and when to skip it)
CQRS is justified when:
- Read and write models diverge significantly
- You need to scale reads independently
- Write logic is complex (validations, workflows, sagas)
- You want async processing on commands
CQRS is overkill when:
- It’s simple CRUD with little business logic
- The team isn’t familiar with the pattern (the learning curve is real)
- The project has 3 months of lifespan
The classic trap: implementing CQRS everywhere from day 1. Better to start with a classic service layer and migrate to CQRS module by module when complexity requires it.
If you’re on Laravel rather than Symfony, the same principles apply with a different implementation. Clean Architecture with Laravel covers separation of responsibilities in the Eloquent context.
In summary
CQRS with Symfony Messenger is:
- Two buses (command + query), configured in YAML
- Immutable messages (readonly PHP 8.2+ classes)
- Isolated and testable handlers
- Native async via Messenger transports
The pattern isn’t complex. What’s complex is knowing when and where to apply it. Start with one module, measure the benefit, and extend progressively.
Kevin De Vaubree
Senior Full-Stack Developer