backFebruary 27, 2025

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:

  1. Clean separation of concerns: Read operations are separate from write operations
  2. Testability: Commands and queries are simple objects that are easy to test
  3. Scalability: You can process commands asynchronously when needed
  4. 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.