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 avecdispatch(). - 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_connectionla 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é, ousync://pour l'exécuter immédiatement.
Supervision des workers
- Utilisez systemd pour gérer les workers en production
- Configurez
--time-limitpour éviter les fuites mémoire - Surveillez la file
failedpour 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:statspour 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.