一键导入
messenger-async
Conçoit un flux Symfony Messenger — message + handler
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Conçoit un flux Symfony Messenger — message + handler
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Cadrage d'un article (sujet, thèse, audience, chapitrage, frontmatter). Détecte la stack (Astro, Hugo, Jekyll, MDX). Produit `docs/story/a-<NNN>-<slug>/plan.md`. Déclenche sur "idée d'article", "plan d'article", "j'écris sur…".
Retouche chirurgicale d'une portion d'article publié (chapitre, section, paragraphe). Lit le `plan.md` associé, respecte la voix, propage à la traduction. Déclenche sur "retravaille cette section", "réécris ce chapitre", "resserre ce paragraphe".
Rédige un article depuis le `plan.md` sous `docs/story/a-<NNN>-<slug>/` — fichier dans la collection détectée (Astro, Hugo, Jekyll, MDX), schéma + traduction. Déclenche sur "rédige depuis ce plan", "écris l'article", "draft l'article".
Crée une entité Sylius traduisible (pattern personal translations) : AbstractTranslation, TranslatableInterface, TranslatableTrait, locale fallback, ajout programmatique. Pour des libellés UI statiques → `/sylius:translation`.
Crée ou modifie une entité Doctrine (Symfony/Sylius) — ORM, champs, relations, types custom. Déclenche sur "créer entité", "relation ManyToOne", "UniqueEntity", "mapping Doctrine". Impose make:entity et snake_case BDD.
Conçoit une classe FormType Symfony — AbstractType, buildForm, configureOptions, types de champs (ChoiceType, EntityType…). Déclenche sur "créer FormType", "buildForm", "data_class", "EntityType". Impose make:form.
| name | messenger-async |
| description | Conçoit un flux Symfony Messenger — message + handler |
| user_invocable | true |
| allowed-tools | ["Read","Write","Edit","Glob","Grep","Bash(ls:*)","Bash(find:*)","Bash(cat:*)","Bash(git:*)","Bash(symfony:*)","Bash(bin/console:*)","Bash(composer:*)","Bash(vendor/bin/*:*)","Bash(./vendor/bin/*:*)"] |
Utilise quand tu déportes un traitement hors du cycle requête (envoi de mail, génération de PDF, webhook, reindex), tu structures un command/query/event bus, ou tu configures retry / DLQ / scheduler. Pas quand tu veux juste émettre un événement applicatif synchrone interne →
/symfony:event-dispatch. Pas quand tu cherches un job CRON simple sans queue ni handler → commande Console +symfony:scheduled-task(ou cron système).
Tu sépares ce qui est demandé (le message, un DTO immutable) de ce qui le traite (le handler, idempotent et indépendant), reliés par un bus (MessageBusInterface) et acheminés via un transport (sync, doctrine, amqp, redis…). Le worker consomme, le retry rejoue les échecs transitoires, la failure transport (DLQ) capture les échecs définitifs.
composer.json — vérifier symfony/messenger. Sinon composer require symfony/messenger.symfony/redis-messenger (faible latence, simple).symfony/amqp-messenger (routing avancé, multi-consommateurs).symfony/doctrine-messenger (le plus simple à déployer, suffisant jusqu'à quelques centaines de messages/s).in-memory:// (test) ou sync:// (debug).config/packages/messenger.yaml — un fichier vide ou absent signifie qu'aucun bus n'est configuré, le dispatch() plantera silencieusement avec un message générique..env pour MESSENGER_TRANSPORT_DSN — souvent défaut doctrine://default?queue_name=default.final, propriétés readonly, pas de service injecté, pas de logique. Sérialisable PHP par défaut (le transport sérialise/désérialise lors du fan-out).#[AsMessageHandler] + __invoke(MessageType $message): void|mixed. Le typehint résout le routage.EntityManager, pas de Request, pas de closures. Une entité Doctrine est techniquement sérialisable, mais le piège est que le handler reçoit un objet détaché : préférer passer l'id et find() côté handler (donnée fraîche, lifecycle géré).command.bus (modifie l'état, un handler), query.bus (lecture, un handler, retourne via HandledStamp), event.bus (notification, N handlers). Par défaut Symfony en crée un seul (messenger.bus.default) — c'est suffisant tant que la séparation n'est pas explicite.messenger.yaml (routing:). Sans entrée, le message est traité synchrone dans le process appelant (utile en dev / test, mais surprend en prod si oublié).bin/console messenger:consume se gère via supervisor / systemd / symfony:run. Jamais lancé depuis une requête web. Toujours --time-limit=3600 --memory-limit=128M pour rebooter sain.failure_transport: failed + un transport failed doctrine pour pouvoir inspecter / rejouer (messenger:failed:show, messenger:failed:retry).DelayStamp (différer), BusNameStamp (router), TransportNamesStamp (forcer un transport), HandledStamp (récupérer le retour côté query bus). Ne pas inventer un mécanisme parallèle pour transporter ce genre d'info.// src/Message/SendOrderConfirmation.php
declare(strict_types=1);
namespace App\Message;
final class SendOrderConfirmation
{
public function __construct(
public readonly int $orderId,
) {}
}
Pas l'entité Order, juste son id. Le handler rechargera depuis la DB.
// src/MessageHandler/SendOrderConfirmationHandler.php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Message\SendOrderConfirmation;
use App\Repository\OrderRepository;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
#[AsMessageHandler]
final class SendOrderConfirmationHandler
{
public function __construct(
private readonly OrderRepository $orders,
private readonly MailerInterface $mailer,
) {}
public function __invoke(SendOrderConfirmation $message): void
{
$order = $this->orders->find($message->orderId)
?? throw new UnrecoverableMessageHandlingException(
"Order #{$message->orderId} disparue, abandon"
);
$this->mailer->send(/* ... */);
}
}
#[AsMessageHandler] suffit, autoconfig wire le tag messenger.message_handler. Le typehint du __invoke résout le routage. UnrecoverableMessageHandlingException court-circuite le retry et envoie direct en DLQ — utile quand la cause est définitive (entité supprimée, donnée invalide).
// src/Order/OrderCreator.php
use App\Message\SendOrderConfirmation;
use Symfony\Component\Messenger\MessageBusInterface;
public function __construct(
private readonly MessageBusInterface $bus,
) {}
public function create(Order $order): Order
{
// ... persist + flush ...
$this->bus->dispatch(new SendOrderConfirmation($order->getId()));
return $order;
}
Autowiring sur MessageBusInterface injecte le bus par défaut. Le dispatch() retourne un Envelope — utile si on veut récupérer un stamp (ex : HandledStamp côté query bus).
# 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
max_delay: 0
failed: 'doctrine://default?queue_name=failed'
routing:
App\Message\SendOrderConfirmation: async
Sans la ligne routing:, le handler tourne dans le process appelant — la requête HTTP attend l'envoi du mail.
# Dev
symfony console messenger:consume async -vv
# Prod (supervisor / systemd)
bin/console messenger:consume async --time-limit=3600 --memory-limit=128M --limit=1000
--time-limit et --memory-limit forcent un redémarrage propre (évite les fuites cumulées). --limit plafonne le nombre de messages avant restart.
dispatch() accepte un Envelope ou un message brut (auto-enveloppé). Pour passer des stamps :
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Stamp\DelayStamp;
use Symfony\Component\Messenger\Stamp\TransportNamesStamp;
$this->bus->dispatch(new Envelope(
new SendOrderConfirmation($order->getId()),
[
new DelayStamp(60_000), // attendre 60s avant traitement
new TransportNamesStamp(['async']), // forcer un transport spécifique
],
));
Stamps usuels :
DelayStamp(ms) — délai avant prise en compte par le worker. Utile pour rappel, timer, retry custom.TransportNamesStamp(['t1', 't2']) — duplique vers plusieurs transports (utile fan-out manuel).BusNameStamp('command.bus') — route vers un bus précis si plusieurs configurés.HandledStamp — posé par le bus après exécution. Côté query bus, $envelope->last(HandledStamp::class)?->getResult() récupère le retour du handler.AmqpStamp / RedisReceivedStamp — métadonnées du transport, lecture seule en général.Récupérer un stamp depuis l'envelope :
$envelope = $this->bus->dispatch($message);
$delay = $envelope->last(DelayStamp::class)?->getDelay();
Quand la séparation devient utile (lecture vs écriture, ou bus d'événements distinct du bus de commandes) :
framework:
messenger:
default_bus: command.bus
buses:
command.bus:
middleware:
- validation
- doctrine_transaction
query.bus:
middleware:
- validation
event.bus:
default_middleware:
allow_no_handlers: true # un événement peut n'avoir aucun listener
Côté handler, contraindre l'attribut au bus :
#[AsMessageHandler(bus: 'command.bus')]
Côté dispatcher, soit autowire l'interface spécifique (Symfony génère App\\command.bus aliasable), soit utiliser l'argument MessageBusInterface $commandBus (autowire par nom).
doctrine_transaction enveloppe le handler dans une transaction DB — tout flush() du handler est commit/rollback en bloc. À mettre uniquement sur le command bus, pas sur le query bus.
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000 # ms de base
multiplier: 2 # exponentiel : 1s, 2s, 4s
max_delay: 0 # 0 = pas de plafond
# service: 'app.custom_retry_strategy' # pour stratégie custom
Logique :
throw new UnrecoverableMessageHandlingException(...) court-circuite le retry et envoie direct en DLQ.RetryStrategyInterface pour décisions par code HTTP, par classe d'exception, etc.framework:
messenger:
failure_transport: failed
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
failed: 'doctrine://default?queue_name=failed'
Sans failure_transport, un message qui crève les retries est perdu silencieusement (juste loggé). Avec, il atterrit dans la table messenger_messages (queue failed) — inspectable et rejouable.
bin/console messenger:failed:show # liste
bin/console messenger:failed:show 42 -vv # détail (stack trace)
bin/console messenger:failed:retry # rejoue tout en interactif
bin/console messenger:failed:retry 42 # rejoue le #42
bin/console messenger:failed:remove 42 # supprime définitivement
En prod : monitorer la profondeur de la queue failed (alerte au-delà de N messages). Une queue qui grossit = bug systématique non vu.
transports:
# Doctrine — simple, suffit jusqu'à ~quelques centaines msg/s
async_doctrine: 'doctrine://default?queue_name=async&auto_setup=true'
# Redis — faible latence, bon throughput, simple
async_redis: '%env(MESSENGER_REDIS_DSN)%' # redis://localhost:6379/messages
# AMQP / RabbitMQ — routing avancé, multi-consommateurs, exchanges
async_amqp:
dsn: '%env(MESSENGER_AMQP_DSN)%'
options:
exchange:
name: 'app'
type: 'topic'
queues:
orders:
binding_keys: ['order.*']
# Sync — exécution dans le process appelant (test, debug)
sync: 'sync://'
# In-memory — uniquement pour les tests fonctionnels
test: 'in-memory://'
auto_setup=true (défaut) crée la table / l'exchange au premier usage. Couper en prod (auto_setup=false) et créer via migration / IaC pour contrôle des privilèges.
L'enchaînement par défaut (add_bus_name_stamp_middleware, dispatch_after_current_bus, failed_message_processing_middleware, send_message, handle_message) est rarement à toucher. Cas réels d'ajout :
validation : valide le message via le composant Validator avant le handler. Utile si le message porte des contraintes (#[Assert\NotBlank], etc.).doctrine_transaction : ouvre/commit/rollback automatiquement autour du handler. Sur le command bus uniquement.MiddlewareInterface, handle(Envelope $envelope, StackInterface $stack): Envelope. Cas usuels : logging structuré, traçage OpenTelemetry, throttling applicatif, multi-tenant context propagation.final class TenantContextMiddleware implements MiddlewareInterface
{
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$stamp = $envelope->last(TenantStamp::class);
if ($stamp !== null) {
$this->tenantContext->switch($stamp->getTenantId());
}
return $stack->next()->handle($envelope, $stack);
}
}
Pour planifier l'envoi récurrent de messages sans cron externe :
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
#[AsSchedule('default')]
final class MainSchedule implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())->add(
RecurringMessage::cron('0 * * * *', new HourlyReportMessage()),
RecurringMessage::every('5 minutes', new HealthCheckMessage()),
);
}
}
Consommer avec un transport dédié scheduler_default (auto-créé) et un worker :
bin/console messenger:consume scheduler_default
Préfère ça à un cron système pour les tâches qui sont déjà des messages — la plomberie (retry, DLQ, observabilité) suit gratuitement.
Le handler n'a aucune dépendance Messenger — juste les services injectés. C'est un test PHPUnit ordinaire :
public function test_it_sends_confirmation(): void
{
$orders = $this->createMock(OrderRepository::class);
$orders->method('find')->with(42)->willReturn($order = $this->makeOrder());
$mailer = $this->createMock(MailerInterface::class);
$mailer->expects(self::once())->method('send');
(new SendOrderConfirmationHandler($orders, $mailer))(
new SendOrderConfirmation(42)
);
}
in-memoryDans config/packages/test/messenger.yaml :
framework:
messenger:
transports:
async: 'in-memory://'
Dans le test :
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
$client = static::createClient();
$client->request('POST', '/orders', [/* ... */]);
/** @var InMemoryTransport $transport */
$transport = self::getContainer()->get('messenger.transport.async');
self::assertCount(1, $transport->getSent());
self::assertInstanceOf(SendOrderConfirmation::class, $transport->getSent()[0]->getMessage());
Pour un test qui veut vérifier le résultat après handling sans démarrer un worker :
framework:
messenger:
transports:
async: 'sync://'
Le dispatch() exécute le handler dans le même process. Plus simple que in-memory quand on veut juste valider l'effet de bord en bout de chaîne.
# Liste les messages routés et leur transport cible
symfony console debug:messenger
# Liste les transports configurés
symfony console debug:container --tag=messenger.receiver
# Inspecter la queue failed
bin/console messenger:failed:show -vv
# Stats live d'un transport doctrine
bin/console dbal:run-sql "SELECT queue_name, count(*) FROM messenger_messages GROUP BY queue_name"
# Worker en mode très verbeux pour voir chaque step
symfony console messenger:consume async -vvv
debug:messenger est le premier réflexe quand un message « part dans le vide » — souvent il est routé vers un transport qui n'a pas de worker en train de tourner.
routing:, le message est exécuté dans le process appelant. La requête HTTP attend. Symptôme : « j'ai branché Messenger, mais ma requête est toujours lente ».flush() plante. Toujours passer l'id.bin/console messenger:stop-workers (ou supervisorctl restart) en post-deploy.UnrecoverableMessageHandlingException court-circuite directement.failed non monitorée : elle croît silencieusement, on découvre 50k messages échoués 6 mois plus tard. Alerter sur count(messenger_messages WHERE queue_name='failed') > N.auto_setup=true en prod : le worker crée la table avec les privilèges du compte applicatif. Préférer une migration et auto_setup=false.#[AsMessageHandler] typant le même message. Symfony exécute les deux. debug:messenger les liste.dispatch() dans une transaction Doctrine pas encore commitée : le worker démarre, find($id) ne trouve pas l'entité (la transaction de l'émetteur n'est pas commit). Solutions : utiliser DispatchAfterCurrentBusMiddleware (par défaut sur la stack), ou émettre après le flush().--memory-limit, un leak (Doctrine UoW qui grossit, gros payload) finit par OOM. --memory-limit=128M + bin/console doctrine:clear en milieu de batch si nécessaire.src/Message/<Nom>.php — DTO final, propriétés readonly, IDs et primitives uniquement.src/MessageHandler/<Nom>Handler.php — #[AsMessageHandler], __invoke, services injectés, UnrecoverableMessageHandlingException pour les cas définitifs.config/packages/messenger.yaml — transport, routing:, failure_transport: failed, retry strategy..env / .env.local — MESSENGER_TRANSPORT_DSN.MessageBusInterface, dispatch() après le flush() éventuel.symfony.cloud workers.symfony console debug:messenger
symfony console messenger:consume async -vv # voir les messages passer
symfony console messenger:failed:show # vérifier que rien n'échoue silencieusement
vendor/bin/phpstan analyse src
Test : unitaire sur le handler, fonctionnel avec in-memory ou sync pour vérifier que l'émetteur dispatche bien.
Afficher :
find() avant action).failed, documenter le contrat du message dans un README.winzou/state-machine + l'EventDispatcher Symfony. Messenger reste utilisable pour le code applicatif custom au-dessus de Sylius (export catalogue, reindex Elasticsearch, webhooks B2B).sylius_order complete, paid, cancelled), ne pas dispatcher un message Messenger depuis un listener — préférer un callback de state machine (sylius_order.yaml) qui pousse ensuite le message si traitement async nécessaire. Ça garde la single source of truth des transitions sur la state machine.composer show sylius/sylius — si v2.x, lire config/packages/messenger.yaml du squelette pour ne pas dupliquer transports / routing./symfony:messenger-async SendOrderConfirmation — scaffolde le couple message + handler, ajoute le routing, n'invente pas le transport (lit la config existante).
/symfony:messenger-async "transport doctrine + DLQ" — patche messenger.yaml pour ajouter failed + failure_transport, sans toucher au reste.
/symfony:messenger-async retry — règle ou ajoute la retry_strategy sur un transport existant.
/symfony:messenger-async scheduler — scaffolde un ScheduleProviderInterface avec #[AsSchedule] + un message récurrent.
/symfony:messenger-async src/MessageHandler/XHandler.php — audit : idempotence, exceptions définitives signalées via UnrecoverableMessageHandlingException, pas d'entité dans le message reçu, transactions cohérentes.
/symfony:messenger-async sans argument — demande le cas (nouveau couple message/handler, retry, DLQ, multi-bus, scheduler, debug d'un message qui « disparaît »).