Aller au contenu principal
Retour au blog

Symfony 8.2 Messenger : Outbox et Claim Check expliqués

Symfony 8.2 ajoute les options outbox et claim_check à Messenger : plus de message fantôme après un rollback, plus de rejet du broker. Config, workers et pièges.

Flavien Métivier · 10 min de lecture
Sommaire · 8 parties

Symfony 8.2 résout deux limites de Messenger avec deux nouvelles options de transport : outbox pour rendre l'envoi d'un message atomique avec ta transaction de base de données, et claim_check pour contourner la taille maximale des messages imposée par le broker. Présentées le 21 septembre 2026 sur le blog officiel, elles arrivent avec Symfony 8.2, attendu en novembre 2026.

Scénario classique : un contrôleur persiste une commande, flush() passe, puis le code dispatche SendConfirmationEmail vers RabbitMQ ou SQS. Si le broker est indisponible à cet instant, la commande est en base et aucun email ne part. L'inverse est tout aussi vicieux : le message part, puis la transaction est annulée, et le worker traite une commande qui n'existe pas. Dans les deux cas, le problème est le même : écrire en base et publier sur un broker ne forment pas une opération atomique.

Cet article fait partie de notre série sur les nouveautés de Symfony 8.2, ouverte avec le découpage de FrameworkBundle en 29 bundles.

Pourquoi Messenger perd des messages ou se les fait refuser

Le premier problème s'appelle le dual-write : deux écritures vers deux systèmes, sans transaction commune. Il produit deux pannes symétriques :

  • La transaction est annulée alors que le message est déjà parti : les consommateurs réagissent à un état qui n’existe pas.
  • Le broker est indisponible alors que les changements sont déjà validés : les données sont sauvegardées, le message n’est jamais émis.

Le contournement habituel consiste à router le message vers le transport Doctrine : l'insert rejoint la transaction métier, l'atomicité est garantie. Il fonctionne, mais la base devient la file que tous tes workers interrogent en boucle, et tu perds la scalabilité d'un vrai broker.

Le second problème n'a rien à voir avec le premier : chaque broker plafonne la taille des messages qu'il accepte. Un export, un document sérialisé ou un lot de lignes trop volumineux, et l'envoi échoue.

Transactional outbox : envoyer un message en toute sécurité avec la base

Le transactional outbox est un pattern qui enregistre le message dans la base de données, dans la même transaction que les données métier, puis le fait relayer vers le broker par un worker dédié. Tu gardes l'atomicité de la base et l'échelle du broker : tes consommateurs continuent de lire RabbitMQ ou SQS comme avant.

Dans Symfony 8.2, tout tient dans l'option outbox d'un transport. Sa valeur est le nom d'un autre transport, où les messages sont stockés avant d'être transmis. Contribution de Nicolas Grekas (PR #65901), qui la présente comme une interprétation propre à Messenger du pattern, pas comme un portage d'une autre bibliothèque.

yaml
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            orders:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                # messages sent to "orders" are stored in "db_outbox" first
                outbox: db_outbox

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

Ton code de dispatch ne change pas d'une ligne : seule la configuration du transport bouge. Il faut en revanche un worker par transport :

bash
# the relay: forwards the stored messages to "orders" (it never runs handlers)
$ php bin/console messenger:consume db_outbox

# handles the messages sent to "orders" as usual
$ php bin/console messenger:consume orders

Le relay ne lance jamais de handler : il transfère les messages vers le transport cible, c'est tout. Il n'existe pas de nouvelle commande console, c'est messenger:consume appliqué au transport outbox qui joue ce rôle.

Outbox : les quatre points à retenir avant de l’activer

  • Même connexion Doctrine. Le transport outbox doit être un transport Doctrine sur la connexion où l’application écrit ses propres données. Avec une autre connexion, il n’y a plus de transaction commune, donc plus d’atomicité.
  • Le délai s’applique dans l’outbox. Un message retardé attend dans l’outbox, puis il est transmis sans délai.
  • Deux chemins de retry. Si le transfert échoue, le relay applique la stratégie de retry et le failure transport de l’outbox. Si c’est le traitement qui échoue, les retries repartent directement vers le transport cible.
  • L’ordre n’est pas garanti. Si ton traitement suppose « créé avant modifié », porte l’ordre dans le message ou rends le handler tolérant.

Tu as donc deux files logiques à superviser, chacune avec sa stratégie de retry et son failure transport. Si tu n'as pas encore industrialisé ces réglages, notre guide pour configurer les workers, le retry et le monitoring de Messenger en production reste la base.

La PR ajoute deux précisions utiles. Un dispatch avec DispatchAfterCurrentBusStamp exécute l'insert après le commit, donc hors de la transaction : à éviter quand tu comptes sur l'outbox. Et le relay décode les messages stockés : pendant un déploiement, il a besoin des classes de message de la version qui les a écrits. Draine l'outbox avant de renommer ou supprimer une classe de message.

Un point que le billet ne tranche pas, et que je pose comme hypothèse de travail : les retries et la relivraison après un crash du relay décrivent une livraison « au moins une fois ». Écris des handlers idempotents. C'était déjà une bonne pratique avec Messenger ; avec l'outbox, elle devient non négociable.

Claim check : contourner la limite de taille du broker

Le claim check est un pattern qui stocke un message volumineux dans un cache et n'envoie au broker qu'une référence légère. Contribution de Yanick Witschi (PR #65641), l'option claim_check se règle par transport, puisque chaque broker a sa propre limite. Elle prend un pool de cache et une taille maximale en octets :

Migration Symfony en vue ?

Bear Upgrade migre votre codebase 2,5× à 4,5× moins cher qu'une ESN, selon sa maturité.

Estimer ma migration →
yaml
# config/packages/messenger.yaml
framework:
    cache:
        pools:
            # a dedicated pool, reachable by producers and consumers
            messenger.claim_check.cache:
                adapter: cache.adapter.redis
                # claims are only removed when they expire
                default_lifetime: 604_800 # 7 days

    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                claim_check:
                    cache_pool: messenger.claim_check.cache
                    # keep some margin below the limit of your broker
                    max_size: 200_000

Le fonctionnement se résume en trois temps :

  • Un message dont la taille encodée (corps et en-têtes) reste sous max_size part normalement.
  • Un message plus gros est stocké dans le pool ; le transport ne porte qu’une référence, faite d’un identifiant aléatoire et d’un checksum.
  • À la consommation, Symfony recharge le message depuis le pool, vérifie le checksum et le traite comme s’il avait voyagé par le transport.

N'importe quel pool PSR-6 convient : Redis, Valkey, Memcached, PDO, Doctrine DBAL et les autres backends du composant Cache. Selon la PR, l'implémentation opère au niveau du serializer et stocke l'enveloppe encodée complète, ce qui la rend indépendante du transport et compatible avec des serializers personnalisés.

Claim check : le pool de cache fait désormais partie de la livraison

C'est la nuance que ce pattern impose : ton cache n'est plus un cache, c'est une pièce du transport. Trois règles en découlent :

  • Utilise un pool joignable par tous les producteurs et tous les consommateurs, et ne le partage pas avec le reste de l’application : le vider supprime les messages en attente.
  • Définis un default_lifetime supérieur à la vie réelle du message : délai, tous les retries et attente dans le failure transport. Les claims ne disparaissent qu’à expiration.
  • Un claim expiré fait échouer la consommation avec une ClaimCheckNotFoundException, enveloppée dans une MessageDecodingFailedException. Le message suit le chemin habituel de retry, mais son contenu est perdu.

La PR détaille d'autres pièges qui méritent ta revue d'architecture. Chaque retry et chaque envoi vers le failure transport stockent un nouveau claim : plusieurs copies d'un même message peuvent coexister, dimensionne le pool en conséquence. Un failure transport sans option claim_check propre reçoit le payload complet. Et un pool cache.adapter.filesystem déclaré dans framework.cache.pools vit dans le répertoire de cache : un cache:clear au déploiement supprime les claims en vol.

Outbox ou claim check : lequel choisir, ou les deux ?

Les deux options répondent à deux problèmes distincts. L'outbox garantit que le message existe si et seulement si la transaction est validée. Le claim check garantit que le broker n'a pas à avaler un payload qu'il refuse.

  • Choisis l’outbox si un message doit suivre un commit : email de confirmation, événement métier, synchronisation avec un système tiers.
  • Choisis le claim check si un payload dépasse la limite du broker et que tu ne peux pas le réduire à court terme.
  • Avant le claim check, essaie d’envoyer un identifiant plutôt que les données, et laisse le handler relire ce dont il a besoin.

Le billet ne montre pas leur combinaison sur un même transport. Je n'en fais donc pas une recette : teste-la avant de t'y fier.

Mise en production dans une application existante

Les deux options sont opt-in, sans dépréciation : rien ne bouge tant que tu ne les configures pas. La PR outbox protège même le câblage du bundle : configurer l'option avec un composant Messenger antérieur à 8.2 lève une erreur explicite. D'ici novembre, prépare le terrain :

  • Liste tes dispatch() placés après un flush() et identifie ceux dont la perte est inacceptable.
  • Repère les projets à plusieurs connexions Doctrine : l’outbox doit vivre sur celle des écritures métier.
  • Vérifie que tes handlers tolèrent un rejeu et un ordre différent.
  • Relève la taille maximale réelle de tes messages et compare-la à la limite de ton broker.
  • Ajoute à ton monitoring la profondeur de l’outbox, son failure transport et le taux d’erreurs de décodage.

Pour suivre ce qui stationne dans l'outbox, les outils d'observation existants suffisent : voir notre article sur l'observabilité de Messenger avec messenger:show et LoggableMiddleware.

Ce qu’il faut retenir

Ma position : active l'outbox sur tout message qui doit suivre un commit. Son coût est réel (un worker de plus, une latence supplémentaire, un ordre non garanti), mais il est connu et borné. Le coût des messages fantômes ne l'est pas : il se découvre en production, au pire moment.

Traite le claim check avec plus de méfiance. Un message léger se passe de pool à surveiller, de TTL à dimensionner et d'un point de panne supplémentaire. Si tu l'adoptes, mets la durée de vie du pool dans ta revue d'architecture, pas dans un coin du YAML. Et relis ces options à la sortie de la 8.2 : la documentation actuelle précède la version stable.

Profiter de ces options suppose d'être sur Symfony 8.2, avec PHP et des dépendances à jour, et des tests qui couvrent tes handlers. Si ta stack a pris du retard, Bear Upgrade prend en charge ce rattrapage : les montées de version sont découpées en Merge Requests testées, que ton équipe relit et merge à son rythme.

Cet article vous a été utile ? Partagez-le.

Portrait de Flavien Métivier, fondateur de The Bearded Bear : barbe longue, lunettes, sur fond sombre

Écrit par

Flavien Métivier

Expert PHP/Symfony indépendant depuis 2014, ancien CTO de Kartable et d'Extrabat. Plus de 80 projets menés, de l'audit à la migration.

Mon parcours

À lire ensuite

Migration Symfony en vue ?

Bear Upgrade migre votre codebase 2,5× à 4,5× moins cher qu'une ESN, selon sa maturité.

Newsletter Bear Bytes

Veille Claude Code, Symfony et PHP, retours d'expérience terrain et conseils techniques, directement dans votre boîte mail.