◀ Retour au blog
PHP / Symfony

Traitement asynchrone avec Symfony Messenger

Publié le 19 Jul 2024· 7 min de lecture
#Symfony#Messenger#Async

Symfony Messenger : le traitement asynchrone simplifié

Le composant Messenger de Symfony permet de décorréler les traitements longs de vos requêtes HTTP pour une meilleure expérience utilisateur.

Envoyer un e-mail, générer un PDF, appeler une API tierce lente ou redimensionner une image : aucune de ces tâches n'a besoin de bloquer la réponse envoyée à l'utilisateur. Avec Messenger, le contrôleur enregistre l'essentiel, publie un message dans une file d'attente et répond immédiatement. Un processus séparé, le worker, traite ensuite le message en arrière-plan.

Les briques de Messenger

  • Le message : un simple objet PHP qui transporte les données nécessaires au traitement. Il ne contient aucune logique.
  • Le handler : la classe qui sait traiter un type de message.
  • Le bus : le point d'entrée, MessageBusInterface, auquel on confie les messages avec dispatch().
  • Le transport : la file d'attente où les messages patientent (RabbitMQ, Redis, table Doctrine, Amazon SQS…).
  • Le worker : la commande messenger:consume, qui lit le transport et appelle les handlers.

Sans configuration de routage, un message est traité de façon synchrone, dans la requête. C'est le routage vers un transport qui rend le traitement asynchrone, sans toucher au code du message ni du handler.

Architecture Message/Handler

// Message
class SendNotificationMessage
{
    public function __construct(
        public readonly int $userId,
        public readonly string $content,
    ) {}
}

// Handler
#[AsMessageHandler]
class SendNotificationHandler
{
    public function __construct(
        private NotificationService $notificationService,
    ) {}

    public function __invoke(SendNotificationMessage $message): void
    {
        $this->notificationService->send(
            $message->userId,
            $message->content,
        );
    }
}

Le message est immuable (readonly) et ne contient que des scalaires : il sera sérialisé pour être stocké dans la file, puis désérialisé par le worker, parfois plusieurs minutes plus tard. Passez un identifiant plutôt qu'une entité Doctrine : le handler rechargera une version fraîche depuis la base. L'attribut #[AsMessageHandler] suffit pour que Symfony associe le handler au message grâce au type de l'argument de __invoke().

Configuration des transports

# config/packages/messenger.yaml
framework:
    messenger:
        failure_transport: failed

        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 2
            failed:
                dsn: 'doctrine://default?queue_name=failed'

        routing:
            App\Message\SendNotificationMessage: async
            App\Message\ProcessOrderMessage: async

La stratégie de réessai est exprimée en millisecondes : en cas d'exception, le message est retenté après 1 seconde, puis 2, puis 4. Après le troisième échec, il part dans le transport désigné par failure_transport, ici une table Doctrine. Sans cette clé, un message qui a épuisé ses tentatives est simplement perdu.

Le DSN du transport principal dépend de votre infrastructure :

# .env
# RabbitMQ (nécessite l'extension PHP amqp)
MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# Redis
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
# Base de données, sans infrastructure supplémentaire
# MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0

Le transport Doctrine est un bon point de départ : il ne demande rien de plus que la base existante. RabbitMQ ou Redis deviennent intéressants quand le volume de messages augmente ou quand plusieurs applications partagent les files.

Dispatch des messages

class OrderController extends AbstractController
{
    public function __construct(
        private OrderService $orderService,
    ) {}

    #[Route('/order', methods: ['POST'])]
    public function create(
        MessageBusInterface $bus,
        Request $request,
    ): JsonResponse {
        // Traitement synchrone rapide
        $order = $this->orderService->create($request);

        // Traitement asynchrone
        $bus->dispatch(new SendNotificationMessage(
            $order->getUserId(),
            "Commande #{$order->getId()} confirmée"
        ));

        return $this->json($order, 201);
    }
}

La création de la commande reste synchrone : l'utilisateur doit savoir tout de suite si elle a réussi. Seule la notification part en file d'attente. Dispatchez le message après l'enregistrement en base : si le worker le traite avant la fin de la transaction, il ne trouvera pas la commande.

Lancer et superviser les workers

En développement, un terminal suffit :

php bin/console messenger:consume async -vv

# En production : redémarrer régulièrement le worker
php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M

Un worker PHP est un processus long : il ne relit pas le code après un déploiement et sa mémoire peut grossir. Les options --time-limit et --memory-limit l'arrêtent proprement, après le message en cours, et le gestionnaire de processus le relance aussitôt. Avec systemd, une unité modèle permet de lancer plusieurs workers :

# /etc/systemd/system/[email protected]
[Unit]
Description=Symfony Messenger worker %i
After=network.target

[Service]
User=app
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now messenger-worker@1 messenger-worker@2

À chaque déploiement, lancez php bin/console messenger:stop-workers : les workers terminent leur message en cours, s'arrêtent, et systemd les relance avec le nouveau code.

Gérer les messages en échec

php bin/console messenger:failed:show
php bin/console messenger:failed:retry
php bin/console messenger:failed:remove 42

Après avoir corrigé un bug, messenger:failed:retry rejoue les messages bloqués. Si une erreur est définitive (utilisateur supprimé, données invalides), levez une UnrecoverableMessageHandlingException dans le handler : le message ne sera pas retenté inutilement.

Pièges courants

  • Handlers non idempotents : Messenger garantit une livraison « au moins une fois ». Un message peut être traité deux fois après un plantage ; le handler doit le supporter sans envoyer deux paiements ou deux e-mails.
  • Connexion base perdue : un worker inactif longtemps peut voir sa connexion MySQL fermée par le serveur. Le middleware doctrine_ping_connection la vérifie avant chaque message.
  • Messages trop lourds : n'y mettez ni entité, ni fichier, ni service. Des identifiants et des scalaires suffisent.
  • Tests : en environnement de test, routez vers le transport in-memory:// pour vérifier qu'un message a été dispatché, ou sync:// pour l'exécuter immédiatement.

Supervision des workers

  • Utilisez systemd pour gérer les workers en production
  • Configurez --time-limit pour éviter les fuites mémoire
  • Surveillez la file failed pour les messages en erreur
  • Utilisez le middleware de logging pour le débogage
  • Arrêtez proprement les workers à chaque déploiement avec messenger:stop-workers
  • Suivez la taille des files avec messenger:stats pour détecter un worker bloqué

Quand ne pas utiliser l'asynchrone

Si l'utilisateur a besoin du résultat pour la suite de son parcours (un paiement à valider, un stock à réserver), le traitement doit rester synchrone ou s'accompagner d'un mécanisme de suivi, comme un statut interrogé par le front. L'asynchrone ajoute aussi une infrastructure à surveiller : pour un traitement de quelques millisecondes, il n'apporte rien. Réservez-le aux tâches lentes, fragiles ou non essentielles à la réponse.

En résumé : des messages petits et immuables, des handlers idempotents, un transport d'échec configuré, des workers redémarrés régulièrement et arrêtés proprement à chaque déploiement. Avec ces quelques règles, Messenger devient une base fiable pour absorber les pics de charge et garder des temps de réponse courts.