Symfony Messenger : configuration avancée, choix du transport et performances

Approche opérationnelle de Symfony Messenger pour PrestaShop : choisir le bon transport, configurer routing et retries, garantir idempotence, tuning des workers et observabilité.

Écrans d'ordinateur affichant du code avec des schémas de configuration pour Symfony Messenger.

Symfony Messenger (Symfony 6.4, PHP 8.2/8.3) n’est pas un « bonus » architectural : c’est une bascule explicite vers des workflows asynchrones, donc vers des contraintes d’exploitation (back-pressure, retries, idempotence, observabilité). Dans un contexte PrestaShop 9 qui s’appuie fortement sur Symfony 6.4 (voir PrestaShop 9 : nouveautés Symfony 6.4, Hummingbird et API Platform), Messenger devient vite la pièce maîtresse dès qu’on sort du « tout synchrone » (indexation, exports, webhooks, emailing, traitements de médias).

Au-delà de la technique, l’enjeu est organisationnel : en asynchrone, vos traitements ne sont plus “immédiatement visibles” par l’utilisateur ni par l’équipe support. Il faut donc documenter les comportements attendus (SLA de traitement, conditions de retry, actions en cas d’échec) et choisir ce que vous acceptez de perdre (au minimum : risque de doublon vs risque de non-livraison).

« The Messenger component helps applications send and receive messages to/from other applications or via message queues. » — Documentation officielle Symfony, Messenger (symfony.com/doc/current/messenger.html)

Table des matières :

  1. Ce que Messenger change vraiment en production (et pourquoi le core n’aide pas toujours)
  2. Choisir le transport : critères concrets (latence, durabilité, ops, coût)
  3. Configuration avancée : routing, multi-transports, sérialisation, middleware
  4. Fiabilité : retries, backoff, dead-letter, idempotence (sinon vous allez rejouer des bugs)
  5. Performances : tuning des workers, préfetch, DB, PHP runtime et scalabilité
  6. Observabilité : métriques, logs, tracing et runbook de traitement des échecs

Ce que Messenger change vraiment en production (et pourquoi le core n’aide pas toujours)

Le cœur de Symfony Messenger est simple : vous envoyez un message (un objet) sur un bus, il est routé vers un transport (sync, AMQP, Doctrine, Redis, etc.), puis un ou plusieurs handlers le consomment. Là où ça se complique, c’est qu’en prod vous êtes obligé de définir des garanties (au minimum : at-least-once vs at-most-once, ordre des messages, délai acceptable, volumétrie) et d’assumer les effets secondaires : doublons, contention DB, explosion du backlog.

Dans beaucoup de projets PrestaShop, l’asynchrone est introduit « pour accélérer » (déporter l’export, décorréler une intégration ERP, lisser un pic). Sauf que si vous ne mettez pas idempotence, timeouts, retries bornés et dead-letter, vous ne gagnez pas de performance : vous déplacez juste l’instabilité vers une file qui se remplit. Le symptôme classique : un worker qui boucle sur des erreurs de validation ou des timeouts externes, et un messenger_messages (Doctrine transport) qui grossit jusqu’à impacter les requêtes.

Un exemple très concret en e-commerce : vous importez 50 000 produits (ERP → PrestaShop) et vous déclenchez une réindexation asynchrone. Tant que tout va bien, les pages catalogue restent fluides. Mais si un handler échoue à cause d’un appel externe (API de recherche, CDN, PIM indisponible), vous pouvez vous retrouver avec :

  • une file “low” qui grossit sans fin ;
  • des retries qui amplifient la charge (mêmes erreurs rejouées) ;
  • une équipe qui “ne voit rien” côté interface, alors que la dette de backlog augmente.

C’est là que Messenger doit être traité comme un système de production (au même niveau que MySQL, PHP-FPM, Redis), pas comme une simple librairie.

Enfin, point spécifique à l’écosystème PrestaShop : une partie du legacy (hooks, ObjectModel, traitements couplés au cycle HTTP) pousse à faire du sync. Messenger est parfaitement utilisable, mais il faut isoler le code de handler du contexte HTTP (pas de dépendance à $_COOKIE, pas d’hypothèse sur la session, pas de rendu). C’est souvent le vrai chantier : découpler le métier du cycle de requête.

Checklist rapide “prod ready” (souvent oubliée au démarrage) :

  • Le message transporte-t-il uniquement ce qui est nécessaire (IDs, scalaires) ?
  • Le handler est-il idempotent (doublon → pas d’effet de bord) ?
  • Les erreurs sont-elles classées : transitoires (retry) vs définitives (dead-letter) ?
  • Avez-vous un SLA implicite/explicite : “webhooks sous 1 min”, “exports sous 15 min”, etc. ?
  • Existe-t-il un plan de purge (messages traités, failed, logs) ?

Choisir le transport : critères concrets (latence, durabilité, ops, coût)

Le « meilleur transport » n’existe pas : vous arbitrez entre durabilité, débit, simplicité d’exploitation et coût. Pour un back-office e-commerce, la question n’est pas “AMQP ou Redis”, c’est : que se passe-t-il si le transport tombe ? Que se passe-t-il si je rejoue 10 000 messages ? Quel est mon SLA sur le traitement ?.

Un moyen pratique d’éviter les débats « religieux » : décider transport par transport en fonction du risque métier.

Besoin dominant Transport souvent adapté Pourquoi (en pratique) Vigilances
Démarrer vite, faible volumétrie, infra minimale Doctrine (SQL) aucun service additionnel contention DB, purge, I/O, backups
Routage, DLQ, priorités, outillage mature RabbitMQ (AMQP) standard, acks, échanges, politiques ops (HA, tuning, connexions)
Latence faible, Redis déjà en place Redis (Streams) simple, rapide, bien intégré cache/sessions persistance, RAM, eviction, garanties
Exploitation simplifiée (managé) SQS / équivalent pas de cluster à maintenir sémantique (visibility timeout), latence, coût, localisation

Contexte “GEO” (au sens très opérationnel) : si vous hébergez votre boutique et vos services dans un datacenter en France/UE (OVHcloud, Scaleway, AWS eu-west, etc.), évitez autant que possible un transport dans une autre région, surtout pour des traitements volumineux. L’asynchrone réduit le couplage applicatif, mais il n’annule pas la latence réseau ni les contraintes de résidence des données (notamment si vos messages contiennent des données personnelles : minimisation, durée de conservation, chiffrement en transit).

Doctrine transport (SQL) : simple, mais attention à la dette d’exploitation

Doctrine transport est tentant parce qu’il ne rajoute aucun service : une table, des indexes, et c’est parti. Sur un petit volume (quelques centaines/minute) ça peut suffire. Mais sur un gros catalogue ou des jobs fréquents (réindexations, webhooks), vous payez :

  • Contention DB (verrous, latence d’UPDATE/DELETE), surtout si votre MySQL/MariaDB est déjà sous pression.
  • Croissance de table (VACUUM/OPTIMIZE, fragmentation, backups plus lourds).
  • Coût d’I/O : la queue devient un workload permanent.
  • Latence “polling” : réduire l’attente augmente le bruit SQL ; l’augmenter dégrade la réactivité.

Si vous partez sur Doctrine transport, vous devez le traiter comme une vraie table critique : index adaptés, purge, et monitoring. Les méthodes d’analyse décrites dans PrestaShop MySQL : analyser slow query log et optimiser index s’appliquent directement (slow log sur les requêtes de polling/ack, taille d’index, plans).

Deux pratiques simples qui évitent beaucoup de douleur :

  • Séparer (au moins) la file “rapide” et la file “lente” même en SQL, pour éviter que les gros jobs masquent les petits.
  • Mettre en place une purge planifiée des messages traités/expirés (et des messages failed si vous les stockez en SQL), avec un seuil de rétention documenté (ex. 7 jours max pour l’investigation).

AMQP (RabbitMQ) : robuste et standard, mais demande de l’opérationnel

AMQP via RabbitMQ reste un choix solide si vous avez besoin de routing, de priorités, de dead-letter exchanges, et d’une gestion propre des acknowledgements. C’est très adapté aux traitements e-commerce “classiques” : envoi d’emails, génération de PDF, synchronisations ERP/CRM, ingestion d’événements.

Le coût est surtout ops : cluster, politiques de quorum/HA, gestion des connexions, tuning du prefetch et des consommateurs. Mais ce coût est souvent moindre que de faire porter la queue à la base de données.

Si vous allez sur RabbitMQ, prévoyez dès le début :

  • une DLQ (dead-letter) distincte par file critique ;
  • une politique de TTL éventuelle pour des messages qui n’ont plus de sens après un délai (ex. relance marketing) ;
  • et une convention d’exchanges/queues stable (noms, vhosts, permissions).

Pour le tuning du prefetch côté consommateurs, la documentation RabbitMQ est claire et utile : Consumer Prefetch.

Redis streams/lists : latence faible, mais garanties à clarifier

Redis peut être excellent pour réduire la latence et limiter la complexité de RabbitMQ, surtout si Redis est déjà présent (cache, sessions, locks). En revanche vous devez clarifier les garanties et les comportements en cas de crash, de redémarrage, et de purge. Redis n’est pas “gratuit” : il faut dimensionner la RAM, maîtriser la persistance (AOF/RDB) et vérifier la politique d’éviction.

Dans un contexte PrestaShop, Redis est souvent déjà dimensionné “cache”, pas “queue”. Or une queue, c’est potentiellement :

  • des pics (soldes, campagnes, imports) ;
  • des messages plus volumineux que prévu (payloads trop riches) ;
  • et une rétention implicite si les workers prennent du retard.

Donc si vous choisissez Redis, imposez une discipline : messages compacts, monitoring mémoire, et test de scénario “transport indisponible / redémarrage”.

SQS (ou équivalent managé) : bon compromis si vous acceptez le cloud

Si votre contrainte est la stabilité opérationnelle plus que la maîtrise fine, une queue managée type AWS SQS est cohérente (et ça s’intègre via des bridges communautaires ou des libs dédiées, même si ce n’est pas le transport Symfony natif le plus direct). L’important est de comprendre le modèle de livraison.

« Standard queues provide at-least-once delivery. » — AWS, Amazon SQS — Standard queues (docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues.html)

Traduction opérationnelle : vos handlers doivent être idempotents, point. Si votre handler “crée une facture” ou “push une expédition”, vous devez empêcher un doublon au niveau métier.

Deux points SQS souvent sous-estimés côté applicatif :

  • le visibility timeout (un message peut réapparaître s’il n’est pas ack à temps) ;
  • et le fait que la notion “d’ordre” dépend de la variante (standard vs FIFO), avec impacts de coût et de débit.

Configuration avancée : routing, multi-transports, sérialisation, middleware

Une configuration Messenger “production-ready” commence par séparer les messages (par criticité et par dépendances externes) et découper les transports. Typiquement : une file “rapide” (jobs courts), une file “lente” (exports, API tierces), une file “critique” (paiements, stock), et un failure transport.

Exemple de base (Symfony 6.4) :

# config/packages/messenger.yaml
framework:
  messenger:
    default_bus: messenger.bus.default
    buses:
      messenger.bus.default:
        middleware:
          - validation
          - doctrine_transaction

    failure_transport: failed

    transports:
      async_high:
        dsn: '%env(MESSENGER_TRANSPORT_DSN_HIGH)%'
        options:
          # dépend du transport, ex: AMQP prefetch_count / queues / exchange
      async_low:
        dsn: '%env(MESSENGER_TRANSPORT_DSN_LOW)%'
      failed:
        dsn: '%env(MESSENGER_TRANSPORT_DSN_FAILED)%'

    routing:
      'App\\Message\\ReindexProductMessage': async_low
      'App\\Message\\SendWebhookMessage': async_high
      'App\\Message\\ExportOrdersMessage': async_low

Deux enrichissements qui aident vraiment à long terme (sans complexifier “pour rien”) :

  • Nommer les files par intention, pas par techno : catalog_low, webhooks_high, exports_low. Le jour où vous migrez Doctrine → RabbitMQ, la config reste lisible.
  • Créer plusieurs bus si vous avez des politiques différentes (ex. bus “command” strict avec validation + transaction, bus “event” tolérant où allow_no_handlers est acceptable). Ça évite d’imposer la même “rigidité” à tous les messages.

Le point sous-estimé : la sérialisation. Par défaut, Symfony utilise son serializer pour transformer l’envelope en payload transportable. Si vous embarquez des objets Doctrine, des DateTime mal normalisés, ou des Value Objects non sérialisables, vous obtenez soit des erreurs, soit des payloads énormes. Le pattern robuste : messages “plats” (IDs, scalaires, petits tableaux) + rechargement en handler.

Concrètement en PrestaShop : au lieu d’envoyer un Product complet, envoyez productId, shopId, et éventuellement une contextVersion si vous avez plusieurs schémas/flux. Ça rend vos messages plus stables (moins couplés au modèle) et réduit les risques en cas de rétention (un message de queue est une donnée stockée).

Et si vous êtes tentés par des sérialisations PHP natives : relisez les impacts sécurité (injection d’objets) détaillés dans Désérialisation PHP : bonnes pratiques pour prévenir l’injection d’objets.

Côté middleware, vous devez être volontaire. doctrine_transaction est pratique, mais dangereux si votre handler fait aussi des appels HTTP externes : vous gardez une transaction ouverte pendant un temps non borné, ce qui peut amplifier la contention. Dans PrestaShop, où la base est souvent le goulot (gros catalogues, requêtes complexes), ce choix doit être mesuré et profilé (voir Dette technique Symfony : profiling Blackfire et refactoring mesurable).

Autre middleware souvent pertinent sur des intégrations : limiter le débit vers une API tierce (ERP, transporteur, OMS) pour éviter le bannissement ou l’effet “tempête” après un incident. Symfony propose un composant RateLimiter, et Messenger peut s’y intégrer via middleware : c’est un bon garde-fou quand on augmente la concurrence côté workers.

Fiabilité : retries, backoff, dead-letter, idempotence (sinon vous allez rejouer des bugs)

La fiabilité avec Messenger ne se “déclare” pas, elle se conçoit. Trois axes :

1) Retry strategy : combien de tentatives, avec quel backoff, et sur quelles exceptions.
2) Failure transport / dead-letter : où vont les messages irrécupérables.
3) Idempotence : votre handler doit tolérer les doublons et les rejouages.

Configurer un retry raisonnable évite le DDoS interne (1000 messages qui échouent et se rejouent en boucle). Exemple :

framework:
  messenger:
    transports:
      async_high:
        dsn: '%env(MESSENGER_TRANSPORT_DSN_HIGH)%'
        retry_strategy:
          max_retries: 5
          delay: 1000      # 1s
          multiplier: 2    # 1s, 2s, 4s, 8s...
          max_delay: 60000 # 60s

En complément de la stratégie “globale”, Symfony permet aussi de piloter le comportement via les exceptions : utilisez UnrecoverableMessageHandlingException quand l’erreur est définitivement non récupérable (payload invalide, entité supprimée, précondition métier non remplie), et une exception “récupérable” quand vous êtes sur un aléa (timeout, 503, rate-limit). Cette simple distinction réduit drastiquement les backlogs “poison”.

Le failure transport n’est pas optionnel. Il vous donne un endroit stable pour investiguer, rejouer, ou purger. Mais attention : un “failed” basé sur Doctrine transport, c’est encore une table qui grossit. Prévoyez une rotation/purge (et un runbook de nettoyage) au même titre qu’un log applicatif. Sur une boutique très active, il est parfois plus sain de stocker les messages failed dans un vrai DLQ (RabbitMQ DLX / SQS DLQ) et d’exposer une commande d’inspection plutôt que d’accumuler en SQL.

L’idempotence se fait au niveau métier, pas au niveau Messenger. Concrètement : introduisez un message id (UUID), stockez-le dans une table “processed_messages” avec une contrainte unique, et short-circuitez si déjà traité.

Un schéma minimaliste (illustratif) :

CREATE TABLE processed_messages (
  message_id CHAR(36) NOT NULL,
  processed_at DATETIME NOT NULL,
  handler VARCHAR(190) NOT NULL,
  PRIMARY KEY (message_id, handler)
);

Cela permet, par exemple, qu’un même message (même message_id) puisse être traité une fois par handler, tout en restant flexible si vous avez plusieurs consommateurs.

Alternative avancée : Outbox pattern (écrire l’événement dans la même transaction que l’état métier, puis un worker “outbox” publie). Ce pattern est souvent le seul moyen propre d’éviter les incohérences “commande créée mais message non envoyé” ou l’inverse. Il est particulièrement pertinent sur des flux critiques (paiement, stock) où une double exécution coûte cher.

Performances : tuning des workers, préfetch, DB, PHP runtime et scalabilité

La performance Messenger se joue dans les workers, pas dans le code de dispatch. Le premier levier : isoler le CPU (handlers lourds) des files critiques. Le second : réduire le coût unitaire par message (I/O, DB, sérialisation). Le troisième : dimensionner la concurrence sans saturer MySQL/MariaDB.

Une règle simple pour éviter le sur-scale “aveugle” : mesurez le temps moyen de traitement.
Si un message prend 200 ms de CPU effectif en moyenne, un worker mono-process plafonne (théoriquement) à ~5 msg/s. Si vos pics sont à 50 msg/s, vous n’avez pas besoin de “beaucoup de magie” : vous avez besoin d’environ 10 workers… sauf si la DB ou une API externe devient le facteur limitant (ce qui arrive très vite en e-commerce).

Consommation : options de messenger:consume et gestion mémoire

En prod, évitez les workers “infinis” sans limites. Utilisez les garde-fous :

php bin/console messenger:consume async_high \
  --time-limit=3600 \
  --memory-limit=256M \
  --limit=2000 \
  -vv

--memory-limit est indispensable : beaucoup de handlers (Doctrine, API clients, templates) ont des fuites “douces” (références conservées, caches statiques). Laisser un worker vivre 48h est un pari. Sur du PHP, un cycle “consume N messages puis restart” est une stratégie de stabilité basique. Combinez avec systemd/supervisor/Kubernetes pour relancer.

Une option utile selon les contextes :

  • --sleep=... pour éviter un polling agressif (notamment sur transport SQL) quand la file est vide.

Pensez aussi au déploiement : en Symfony, la commande messenger:stop-workers permet de demander un arrêt propre des workers (ils terminent le message en cours puis s’arrêtent). Cela évite les redéploiements “brutaux” qui multiplient les redeliveries.

Si vous déployez via conteneurs, soignez la topologie : un service “workerhigh” et un service “workerlow” séparés, chacun scale indépendamment. L’article Docker Compose : orchestrer des services multi-conteneurs en production fournit une base solide pour structurer ces services (logs, restart policy, ressources).

Préfetch/QoS : le piège classique de la “concurrence” qui détruit la latence

Sur AMQP, le paramètre qui compte est souvent prefetch_count (QoS) : trop élevé, un consumer “réserve” beaucoup de messages et peut créer de la latence pour les autres consumers ; trop faible, vous perdez en débit. Le bon réglage dépend du temps moyen de traitement et du nombre de workers. En pratique : commencez bas (10–50), mesurez, augmentez progressivement.

Un anti-pattern courant sur des webhooks : augmenter le nombre de workers et le prefetch, puis constater que les webhooks “critiques” arrivent plus tard… parce qu’un consumer a préfetché un gros lot de messages lents. D’où l’intérêt de séparer files rapides/lentes et de traiter les dépendances externes avec un débit maîtrisé.

Sur DB transport, le polling a un coût : si vous descendez l’intervalle pour réduire la latence, vous augmentez les requêtes. Si votre DB est déjà limite, vous allez le voir dans le slow log. Ce point rejoint les optimisations plus globales côté PHP/DB : OPcache correctement dimensionné (PHP OPcache : paramètres recommandés pour optimiser les performances), et surveillance des requêtes N+1 ou du SQL généré.

Accès DB dans les handlers : pooling… inexistant, et donc discipline obligatoire

En PHP-FPM “classique”, chaque process worker a son propre cycle de vie, et côté DB vous n’avez pas un pooling applicatif comme en Java. Dans un handler Messenger, c’est pareil : si vous ouvrez trop de connexions, vous allez juste saturer MySQL. Les stratégies efficaces :

  • Réduire le nombre de workers concurrents par rapport à max_connections DB et au CPU.
  • Batch côté handler quand c’est possible (traiter 100 IDs dans une requête plutôt que 100 requêtes).
  • Optimiser les index et la pagination sur les gros volumes, comme détaillé dans Performance API : index, pagination et pool de connexions (mêmes mécanismes : coût des OFFSET, index composite, charge CPU).
  • Éviter de charger des graphes d’objets Doctrine “trop riches” : hydratez ce qui est utile, et libérez (clear) si vous traitez en lot.

C’est contre-intuitif mais fréquent : “scaler les workers” dégrade le débit global si la DB est le goulot. La bonne métrique n’est pas le nombre de workers, c’est le temps de traitement moyen + l’attente I/O et la taille de backlog.

Observabilité : métriques, logs, tracing et runbook de traitement des échecs

Sans observabilité, Messenger devient une boîte noire. Au minimum, vous voulez : (1) backlog par queue, (2) taux d’échec, (3) durée moyenne de traitement, (4) âge du plus vieux message, (5) volume de retries. RabbitMQ expose ces métriques nativement ; pour Doctrine transport, vous devrez les dériver via SQL (compter par queue_name, available_at, delivered_at) et les exporter vers Prometheus/Grafana ou votre stack.

Astuce simple côté Symfony : la commande messenger:stats (si disponible dans votre version/installation) donne un aperçu rapide des transports et du nombre de messages. C’est un bon outil “premier diagnostic” avant d’aller dans Grafana.

Pour Doctrine transport, une requête du type (à adapter au schéma exact) permet de suivre la volumétrie et l’âge :

  • nombre de messages disponibles par queue ;
  • timestamp du plus ancien message “disponible”.

Côté logs, activez une verbosité raisonnable en prod (pas -vvv en permanence) mais injectez du contexte : message class, message id, correlation id, tenant/shop id si multiboutique. Si vous avez une chaîne de services (webhook → queue → API ERP), un trace id propagé dans les headers/payloads change la vie.

Pour du tracing distribué, OpenTelemetry est aujourd’hui la base (voir opentelemetry.io) ; Symfony a des intégrations via bundles/bridges selon votre stack. Même sans tracing complet, un simple correlation_id (UUID) loggé côté front + workers + API tierce (quand possible) accélère énormément les RCA (root cause analysis).

Enfin, écrivez un runbook. Pas un PDF : des commandes concrètes.

  • Lister les messages failed :
  • php bin/console messenger:failed:show
  • Comprendre la cause sur un message précis :
  • php bin/console messenger:failed:show <id>
  • Rejouer (après correction) :
  • php bin/console messenger:failed:retry
  • Supprimer les messages “poison” (quand vous assumez la perte) :
  • php bin/console messenger:failed:remove
  • Purger un backlog “poison” (ex : messages invalides) sans casser la prod : documenter une procédure (désactiver temporairement le dispatch, isoler la file, stopper les workers, purge contrôlée, redémarrage).
  • Éviter que l’espace disque explose (logs + DLQ + DB). Sur Doctrine transport, la maintenance DB est réelle : si vous laissez les tables grossir, vous vous retrouvez à “nettoyer en urgence”. Sur PrestaShop, des outils de maintenance peuvent aider côté base, mais ils ne remplacent pas une stratégie de purge côté queue (voir MedCleanMyShop PrestaShop : nettoyage base de données et fichiers automatisé pour la logique de nettoyage, en gardant en tête que les messages sont des données applicatives, pas des “déchets”).

Le résultat attendu d’une configuration avancée de Symfony Messenger n’est pas “ça marche”, c’est : ça reste stable sous charge, ça se diagnostique en 10 minutes, et ça se corrige sans rejouer des effets de bord. Sur un e-commerce, c’est la différence entre un incident contenu et une journée à courir après des commandes incohérentes.


À lire aussi