Blog / Symfony

Symfony 8.2 : l’outbox de Messenger, un filet pour votre legacy

Messenger 8.2 intègre l’outbox transactionnel et le claim check : de quoi fiabiliser la couture entre un monolithe historique et ses nouveaux services.

Par Romain Barberi 6 min de lecture 2 sources

Le 21 septembre 2026, le blog Symfony a présenté deux nouveautés de Messenger dans Symfony 8.2 : le transactional outbox et le claim check. La première règle un problème que presque toutes les applications à message ont un jour : enregistrer en base puis publier sur un broker, sans atomicité entre les deux. Ce problème est surtout aigu dans un legacy en cours de migration, parce que le découpage progressif crée précisément une frontière où des messages doivent traverser.

Ma thèse est simple : l’outbox natif transforme un pattern qu’on écrivait à la main, souvent de travers, en quelques lignes de configuration. C’est l’un des chantiers à meilleur rendement d’une montée vers 8.2, à condition de comprendre ce qu’il garantit et ce qu’il ne garantit pas.

Le bug qui n’apparaît qu’en production

Le scénario est banal. Un contrôleur ou un handler enregistre une commande en base, puis dispatche un message vers RabbitMQ ou SQS pour prévenir le reste du système. Deux écritures, deux systèmes, aucune transaction commune.

Deux échecs sont possibles. Si la transaction est annulée après l’envoi du message, le broker transporte l’annonce d’un fait qui n’a pas eu lieu. Si le broker est indisponible au moment de l’envoi, la donnée est enregistrée mais personne n’en est informé. Dans les deux cas, rien ne casse bruyamment : les systèmes divergent en silence et quelqu’un finit par réconcilier à la main.

Sur les migrations que j’ai menées, cet endroit existe presque toujours quelque part dans le monolithe : un « on enregistre, puis on envoie » sans filet. Ça marche la plupart du temps, ce qui est exactement ce qui le rend dangereux.

Ce que l’outbox de Messenger 8.2 fait concrètement

Le principe est celui du transactional outbox. Le message n’est pas envoyé au broker : il est écrit en base, dans la même transaction que vos données métier. Un worker dédié, le relais, lit ensuite ces messages stockés et les transmet au vrai broker. Les consommateurs, eux, lisent le broker comme avant.

Selon l’article du blog Symfony, la configuration se réduit à une option sur le transport :

# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            orders:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                # les messages envoyés vers "orders" passent d'abord par "db_outbox"
                outbox: db_outbox

            db_outbox: 'doctrine://default?queue_name=outbox'

Et deux workers à faire tourner :

# le relais : transmet les messages stockés vers "orders" (il n'exécute aucun handler)
php bin/console messenger:consume db_outbox

# traite les messages de "orders" comme d'habitude
php bin/console messenger:consume orders

Le point important est que votre code applicatif ne change pas. Vous dispatchez toujours le même message sur le même bus, dans votre transaction Doctrine ; c’est le transport qui fait la différence. Le gain de migration est là : le pattern se déploie sans réécrire les handlers, message par message si vous le souhaitez.

Les contraintes à lire avant d’activer

L’article est net sur les limites, et elles comptent plus que la configuration.

  • L’outbox doit être un transport Doctrine utilisant la même connexion que votre application. C’est ce qui rend l’écriture atomique ; si vos données métier vivent dans une autre base ou une autre connexion, la garantie disparaît.
  • L’ordre des messages n’est pas garanti pendant la transmission. Pour un flux où « commande créée » doit précéder « commande payée », ce n’est pas un détail.
  • Le délai d’un message s’applique pendant son attente dans l’outbox ; une fois transmis, il n’a plus de délai.
  • Si la transmission échoue, c’est la stratégie de retry et le failure transport de l’outbox qui s’appliquent. Si le traitement échoue, les nouvelles tentatives repartent directement vers le transport cible, car le message est déjà passé par l’outbox.

Ce dernier point mérite une vérification en préproduction : vous aurez désormais deux endroits où un message peut échouer, avec deux stratégies de reprise. Il faut savoir, avant l’incident, lequel des deux failure transports regarder.

Un corollaire que l’article ne développe pas et qui relève du pattern lui-même : une livraison fiable de ce type est de l’ordre de « au moins une fois ». Vos consommateurs doivent donc tolérer de recevoir deux fois le même message. Si ce n’est pas le cas aujourd’hui, l’outbox rend le sujet visible, ce qui n’est pas plus mal, mais il faut le traiter.

Le claim check, pour les messages trop gros

La seconde nouveauté répond à un autre problème : les brokers limitent la taille des messages. Avec le claim check, un message dont la taille encodée (corps et en-têtes) dépasse un seuil est stocké dans un pool de cache, et seule une référence (un identifiant aléatoire et une somme de contrôle) est envoyée.

framework:
    cache:
        pools:
            # un pool dédié, accessible aux producteurs et aux consommateurs
            messenger.claim_check.cache:
                adapter: cache.adapter.redis
                # les claims ne sont supprimés qu'à l'expiration
                default_lifetime: 604_800 # 7 jours

    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                claim_check:
                    cache_pool: messenger.claim_check.cache
                    # gardez une marge sous la limite de votre broker
                    max_size: 200_000

Le piège tient en une phrase de la documentation : le pool de cache fait désormais partie de la livraison des messages. Ne le partagez pas avec le cache applicatif, car un cache:clear ferait disparaître des messages en attente. Réglez default_lifetime au-delà de la durée de vie réelle d’un message, retries et attente dans le failure transport compris. Un claim expiré produit une ClaimCheckNotFoundException, encapsulée dans une MessageDecodingFailedException, qui suit le chemin habituel de retry et de failure transport.

Sur un legacy, je vois deux cas d’usage : les exports ou synthèses volumineux qu’on poussait dans un message faute de mieux, et les payloads qui ont grossi au fil des ans jusqu’à frôler la limite du broker.

Pourquoi c’est un chantier de migration, pas seulement une amélioration

Une migration progressive, de type strangler, crée mécaniquement une couture : l’ancien monolithe d’un côté, les nouveaux services de l’autre, et des messages entre les deux. C’est là que la double écriture fait le plus de dégâts, parce que chaque incohérence est difficile à attribuer : l’ancien code, le nouveau, ou le transport ?

Mon avis est qu’il faut sécuriser cette couture avant de déplacer de la logique métier au-delà. Un outbox en place fait de la couture un point fixe : ce qui est en base est ce qui sera publié, et l’on peut, en cas de doute, compter les lignes de la table d’outbox plutôt que fouiller des logs de broker.

Je commencerais par les messages critiques (paiement, facturation, changement de statut de commande) et non par tout le bus d’un coup. Chaque transport a sa propre option outbox, ce qui permet cette progression.

Les limites

L’outbox n’est pas gratuit. Vous ajoutez une table qui grossit, un worker de plus à superviser, et de la latence : un message passe par la base avant d’atteindre le broker. Pour un flux où la milliseconde compte, ce n’est peut-être pas le bon compromis.

L’absence de garantie d’ordre est la limite la plus sérieuse. Si votre métier dépend d’une séquence stricte pour un même agrégat, il faudra soit rendre les consommateurs tolérants au désordre, soit trouver une autre approche. Je n’ai pas vu, dans l’article, de mécanisme de partitionnement ; je ne prétends donc pas que ce cas soit couvert.

Il existe aussi des alternatives plus lourdes, comme la capture des changements directement dans le journal de la base, qui évitent d’écrire dans une table d’outbox. Elles demandent une infrastructure que la plupart des équipes avec un monolithe historique n’ont pas, et je les réserverais aux cas où le volume l’impose.

Enfin, tout cela suppose Symfony 8.2. Sur une application encore en 6.4 ou 7.4, le pattern reste faisable à la main, mais l’argument « une ligne de configuration » ne tient plus. C’est une raison de plus de planifier la montée de version.

Concrètement, par où commencer

  1. Repérez dans votre code les endroits où un flush() ou un commit est suivi d’un dispatch vers un broker. Une recherche sur le bus de messages suffit pour un premier inventaire.
  2. Classez ces messages par criticité. Les paiements et la facturation d’abord.
  3. Vérifiez que vos consommateurs supportent un message reçu deux fois, et un message reçu dans le désordre. C’est le vrai pré-requis, plus que la version de Symfony.
  4. Sur un environnement de préproduction, activez l’outbox sur un seul transport, faites tourner le relais et provoquez des pannes du broker. Observez où atterrissent les échecs.
  5. Décidez qui surveille la table d’outbox et le relais : une alerte sur l’ancienneté du plus vieux message non transmis est un bon indicateur.

Si vous préparez une montée vers 8.2 sur une application ancienne, cet inventaire des écritures doubles est un bon premier jalon : il livre de la fiabilité avant même que la migration ne commence, et cette valeur-là se voit tout de suite.

Sources

À lire aussi

Et votre socle, il en est où ?

Audit d’architecture, montée de version PHP ou Symfony, fiabilisation d’un existant : nous travaillons au forfait, avec un périmètre et un prix fixés avant de commencer.