Simplified CQRS with Symfony 7.2 Messenger
Have you ever found yourself wrestling with complex application logic, where reads and writes to your database are all mixed up? Maybe you've heard about this pattern called CQRS (Command Query Responsibility Segregation) but thought it seemed too complicated to implement? Symfony 7.2's Messenger component makes it easy!
In this post, I'll walk you through implementing CQRS in a Symfony 7.2 project with practical code examples that you can start using today.
What is CQRS?
Before jumping into the code, let's quickly cover what CQRS actually is:
CQRS separates your application into two parts:
- Commands: These handle write operations (create, update, delete)
- Queries: These handle read operations (get, list, search)
By splitting these responsibilities, your code becomes cleaner and more maintainable. It opens up possibilities for scaling as well, you could even use different databases for reads and writes if needed.
Setting up Symfony Messenger
First, let's make sure we have Messenger installed in our Symfony 7.2 project:
composer require symfony/messenger
Next, we need to configure Messenger in our config/packages/messenger.yaml
file:
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://'
routing:
# Route your messages to the transports
'App\CQRS\Command\*': async
This configuration tells Messenger to route all commands to an asynchronous transport. You can set up the transport DSN in your .env
file:
# .env
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=true
Creating the Command Structure
Now, let's create our command structure. I'll use a simple blog post example:
// src/CQRS/Command/CreatePostCommand.php
namespace App\CQRS\Command;
class CreatePostCommand
{
public function __construct(
public readonly string $title,
public readonly string $content,
public readonly string $authorId,
) {
}
}
Now we need a handler for this command:
// src/CQRS/CommandHandler/CreatePostHandler.php
namespace App\CQRS\CommandHandler;
use App\CQRS\Command\CreatePostCommand;
use App\Entity\Post;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class CreatePostHandler
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {
}
public function __invoke(CreatePostCommand $command): void
{
$post = new Post();
$post->setTitle($command->title);
$post->setContent($command->content);
$post->setAuthorId($command->authorId);
$post->setCreatedAt(new \DateTimeImmutable());
$this->entityManager->persist($post);
$this->entityManager->flush();
}
}
The #[AsMessageHandler]
attribute is what connects our handler to Messenger. When a CreatePostCommand
is dispatched, Messenger will route it to this handler.
Setting up the Query Structure
For queries, we'll create a similar structure:
// src/CQRS/Query/GetPostQuery.php
namespace App\CQRS\Query;
class GetPostQuery
{
public function __construct(
public readonly int $id,
) {
}
}
And its handler:
// src/CQRS/QueryHandler/GetPostHandler.php
namespace App\CQRS\QueryHandler;
use App\CQRS\Query\GetPostQuery;
use App\Repository\PostRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class GetPostHandler
{
public function __construct(
private readonly PostRepository $postRepository,
) {
}
public function __invoke(GetPostQuery $query): ?object
{
return $this->postRepository->find($query->id);
}
}
Using Commands and Queries in Controllers
Now, let's see how we can use these commands and queries in our controllers:
// src/Controller/PostController.php
namespace App\Controller;
use App\CQRS\Command\CreatePostCommand;
use App\CQRS\Query\GetPostQuery;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;
use Symfony\Component\Routing\Attribute\Route;
class PostController extends AbstractController
{
public function __construct(
private readonly MessageBusInterface $commandBus,
private readonly MessageBusInterface $queryBus,
) {
}
#[Route('/post/new', name: 'post_new', methods: ['POST'])]
public function new(Request $request): Response
{
$data = json_decode($request->getContent(), true);
$command = new CreatePostCommand(
$data['title'],
$data['content'],
$this->getUser()->getId(),
);
// Send the command to the message bus
$this->commandBus->dispatch($command);
return $this->json(['status' => 'Post created']);
}
#[Route('/post/{id}', name: 'post_show', methods: ['GET'])]
public function show(int $id): Response
{
$query = new GetPostQuery($id);
// Send the query to the message bus and get the result
$envelope = $this->queryBus->dispatch($query);
// Get the post from the envelope
$handledStamp = $envelope->last(HandledStamp::class);
$post = $handledStamp->getResult();
if (!$post) {
throw $this->createNotFoundException('Post not found');
}
return $this->json($post);
}
}
Wait, we're using two message buses here? Yup! In a proper CQRS setup, it's good practice to separate your command and query buses.
Let's configure that in our services.yaml
:
# config/services.yaml
services:
# ...
command.bus:
class: Symfony\Component\Messenger\MessageBus
arguments:
- !tagged_iterator messenger.bus.command.middleware
query.bus:
class: Symfony\Component\Messenger\MessageBus
arguments:
- !tagged_iterator messenger.bus.query.middleware
And update our messenger.yaml
configuration:
# config/packages/messenger.yaml
framework:
messenger:
default_bus: command.bus
buses:
command.bus:
middleware:
- validation
- doctrine_transaction
query.bus:
middleware:
- validation
# ...rest of configuration
This setup gives us separate buses for commands and queries, with different middleware. For example, we wrap command handling in a database transaction, but queries don't need that.
Handling Asynchronous Commands
One of the cool things about Messenger is that it can handle commands asynchronously. This means your web request can return immediately, and the command will be processed in the background.
To run the messenger consumer that processes async messages:
php bin/console messenger:consume async
This is great for operations that might take a while, like sending emails or processing images.
The Benefits of This Approach
By using Symfony Messenger for CQRS, there are several benefits:
- Clean separation of concerns: Read operations are separate from write operations
- Testability: Commands and queries are simple objects that are easy to test
- Scalability: You can process commands asynchronously when needed
- Maintainability: Code is organized around business operations rather than entities
Advanced Tips
Here are a few advanced tips for when you're comfortable with the basics:
Using Command Results
Sometimes you need to get a result from a command, like the ID of a newly created entity. You can modify your command handler to return the ID:
// src/CQRS/CommandHandler/CreatePostHandler.php
public function __invoke(CreatePostCommand $command): int
{
// ... create post logic
return $post->getId();
}
And then in your controller:
$envelope = $this->commandBus->dispatch($command);
$handledStamp = $envelope->last(HandledStamp::class);
$postId = $handledStamp->getResult();
Multiple Read Models
A powerful feature of CQRS is that you can have multiple read models optimized for different use cases. For example, you might have:
- A detailed post model for showing a single post
- A summary model for listing posts
- A search model for finding posts
Each can have its own query and handler.
Conclusion
Implementing CQRS in a Symfony 7.2 project using Messenger is surprisingly straightforward. It helps you organize your code better and opens up possibilities for scaling your application.
The best part is that you can start small - maybe just apply CQRS to one complex part of your application and see how it goes. You don't have to rewrite your entire application at once.