Implémenter CQRS avec Symfony Messenger : Guide Pratique


CQRS est l’un des patterns les plus puissants — et les plus mal compris — de l’écosystème PHP. Beaucoup pensent qu’il nécessite Event Sourcing, une infrastructure complexe et 200 classes pour un CRUD. Faux. Avec Symfony Messenger, on peut implémenter un CQRS pragmatique en quelques heures.

CQRS en 5 minutes

CQRS sépare votre application en deux côtés :

  • Commands : modifient l’état (créer, modifier, supprimer). Retournent void ou un identifiant.
  • Queries : lisent l’état. Retournent des données. Ne modifient jamais rien.

Chaque côté a son propre bus, ses propres handlers, et potentiellement ses propres modèles de données. La séparation peut être logique (même base de données) ou physique (bases distinctes).

Pourquoi c’est utile ? Parce que les besoins en lecture et en écriture divergent presque toujours. Les lectures veulent des données dénormalisées et rapides. Les écritures veulent de la validation, des invariants métier, et de la cohérence.

Pour comprendre comment CQRS s’inscrit dans une stratégie architecturale plus large, voir mon article sur l’architecture logicielle pour applications web modernes.

Configurer Symfony Messenger pour CQRS

Le Command Bus

Le Command Bus route les commandes vers leur handler unique. Une commande = un handler. Pas de négociation.

# 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,
    ) {}
}

Le doctrine_transaction middleware wrappe automatiquement chaque command dans une transaction. Si le handler lève une exception, tout est rollback.

Le Query Bus

Le Query Bus est plus simple : pas de transaction, pas d’effets de bord. Il retourne des données.

// src/Query/GetOrderSummaryQuery.php
final readonly class GetOrderSummaryQuery
{
    public function __construct(
        public string $orderId,
    ) {}
}

Les 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();
    }
}

Le handler est la seule classe qui orchestre la logique. Le domaine (Order::place) contient les règles métier. Le repository persiste. L’event notifie.

Exemple concret : un module e-commerce

Prenons un tunnel de commande complet :

Commands :

  • AddToCartCommand → ajoute un produit au panier
  • PlaceOrderCommand → valide et crée la commande
  • ProcessPaymentCommand → lance le paiement (async via transport)

Queries :

  • GetCartQuery → retourne le panier avec prix calculés
  • GetOrderSummaryQuery → retourne le résumé post-commande
  • ListUserOrdersQuery → historique paginé

Les commands passent par le command.bus avec transaction Doctrine. Les queries passent par le query.bus sans middleware de transaction. Le ProcessPaymentCommand est routé vers un transport async (RabbitMQ, Redis) pour ne pas bloquer l’utilisateur.

Tester son implémentation CQRS

CQRS rend les tests naturellement plus simples. Chaque handler est une unité isolée.

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));
}

Pas de base de données, pas de kernel Symfony. Le handler se teste avec des doublures en mémoire. C’est rapide, déterministe, et maintenable.

Pour aller plus loin sur les stratégies de test, notamment en E2E, consultez mon article sur les tests E2E avec Playwright.

Quand utiliser CQRS (et quand s’en passer)

CQRS est justifié quand :

  • Les modèles de lecture et d’écriture divergent significativement
  • Vous avez besoin de scaler les lectures indépendamment
  • La logique d’écriture est complexe (validations, workflows, sagas)
  • Vous voulez du traitement asynchrone sur les commandes

CQRS est overkill quand :

  • C’est un CRUD simple avec peu de logique métier
  • L’équipe n’est pas familière avec le pattern (la courbe d’apprentissage est réelle)
  • Le projet a 3 mois de durée de vie

Le piège classique : implémenter CQRS partout dès le jour 1. Mieux vaut commencer avec un service layer classique et migrer vers CQRS module par module quand la complexité l’exige.

Si vous êtes sur Laravel plutôt que Symfony, les mêmes principes s’appliquent avec une implémentation différente. La Clean Architecture avec Laravel aborde la séparation des responsabilités dans le contexte Eloquent.

En résumé

CQRS avec Symfony Messenger, c’est :

  • Deux bus (command + query), configurés en YAML
  • Des messages immutables (readonly classes PHP 8.2+)
  • Des handlers isolés et testables
  • Du async natif via les transports Messenger

Le pattern n’est pas complexe. Ce qui est complexe, c’est de savoir quand et l’appliquer. Commencez par un module, mesurez le bénéfice, et étendez progressivement.

KD

Kevin De Vaubree

Développeur Full-Stack Senior