Aller au contenu principal
Retour au blog

Ordre des listeners Symfony 8.2 : before, after, within

Symfony 8.2 remplace les priorités devinées par before, after, within et around : ordonne listeners, décorateurs et handlers sans casser tes mises à jour.

Flavien Métivier · 11 min de lecture
Sommaire · 9 parties

Depuis Symfony 8.2, les options before et after ordonnent un listener ou un service taggé par rapport à un autre, sans connaître sa priorité numérique. within et around font la même chose pour les décorateurs. L'ordre est résolu à la compilation du conteneur, sans coût à l'exécution. Présentée le 6 octobre 2026 sur le blog officiel, contribution de Nicolas Grekas (PR #66012 et suivantes), la fonctionnalité arrive dans Symfony 8.2, attendu en novembre 2026.

Ouvre ton services.yaml et cherche priority:. Tu y trouveras un 255, un -1024, peut-être un 17 qui traîne depuis trois ans. Demande à l'équipe pourquoi ces valeurs-là. Personne ne sait. Quelqu'un a lu le code du LocaleListener, a pris une valeur un peu supérieure, et ça a marché le jour où il l'a fait.

Pourquoi les priorités numériques d’un listener Symfony sont fragiles

Une priorité numérique exprime une relation (« avant le listener de Symfony », « après le handler d'audit ») avec une valeur absolue qui ne la documente pas. Si le bundle voisin change sa propre priorité dans une mise à jour mineure, ton ordre casse sans qu'aucun test ni aucune erreur de build ne le signale. Le billet officiel résume la méthode actuelle : trouver la priorité du listener cible, choisir un nombre un peu supérieur, et espérer qu'elle ne change pas.

Le cas le pire est celui de l'issue #64580 : deux services de deux bundles que tu ne maîtrises pas partagent la même priorité, et tu veux t'intercaler entre eux. Aucun entier ne l'exprime. La PR #66012 le formule sans détour : inventer un nombre ne résout pas le problème. La PR #66240 en donne un exemple dans Symfony lui-même : un décorateur de http_client à la priorité 7, commenté « entre TraceableHttpClient (5) et RetryableHttpClient (10) », alors que ces deux services décorent désormais à d'autres priorités. Le commentaire s'est périmé en silence.

Comment exécuter un listener avant ou après un autre avec #[AsEventListener]

Cas d'école du billet : ton listener pose l'attribut de requête _locale, il doit donc s'exécuter avant que le LocaleListener de Symfony le lise. Tu nommes la cible au lieu de deviner sa priorité :

php
// src/EventListener/RequestLocaleListener.php
namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\EventListener\LocaleListener;

#[AsEventListener(before: LocaleListener::class.'::onKernelRequest')]
final class RequestLocaleListener
{
    public function __invoke(RequestEvent $event): void
    {
        // ...
    }
}

before et after acceptent un identifiant de service ou un nom de classe, ou un tableau des deux. Le suffixe ::onKernelRequest cible une méthode précise ; il compte ici, car LocaleListener écoute kernel.request avec deux méthodes. Sans suffixe, ton listener passerait avant les deux. Les subscribers enregistrés comme services gagnent les mêmes options, via des clés nommées dans getSubscribedEvents() (PR #66022) :

php
public static function getSubscribedEvents(): array
{
    return [
        KernelEvents::REQUEST => [
            'method' => 'onKernelRequest',
            'before' => LocaleListener::class.'::onKernelRequest',
        ],
    ];
}

Quatre règles encadrent le mécanisme, d’après le billet :

  • L’ordre est résolu à la compilation du conteneur, sans travail supplémentaire à l’exécution.
  • Sans priorité, Symfony la choisit. Avec une priorité explicite, les contraintes ne réordonnent que les listeners de cette même priorité.
  • Une référence vers un listener absent de l’événement est ignorée : c’est voulu, pour supporter les bundles optionnels.
  • Priorités en conflit, contraintes circulaires et méthodes invalides provoquent une erreur de compilation.

Comment ordonner des services taggés avec #[AsTaggedItem]

Même mécanique pour les services taggés. Scénario du billet : un bundle d'import fournit des handlers CSV et XLSX de même priorité, et tu veux insérer ton handler TSV entre les deux :

php
// src/Handler/TsvHandler.php
namespace App\Handler;

use Acme\ImportBundle\Handler\CsvHandler;
use Acme\ImportBundle\Handler\XlsxHandler;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

#[AsTaggedItem(after: CsvHandler::class, before: XlsxHandler::class)]
class TsvHandler
{
    // ...
}
yaml
# config/services.yaml
services:
    App\Handler\TsvHandler:
        tags:
            - name: 'app.handler'
              after: 'Acme\ImportBundle\Handler\CsvHandler'
              before: 'Acme\ImportBundle\Handler\XlsxHandler'

Piège à connaître : #[AsTaggedItem] configure l'ordre, il n'ajoute pas le tag. Ton handler doit déjà porter le tag du bundle, par exemple via l'autoconfiguration. Sur ce sujet, notre aperçu de Symfony 8.2 détaillait les évolutions d'AutoconfigureTag.

Décorateurs Symfony : quelle différence entre within et around ?

Quand plusieurs décorateurs enveloppent un service, leurs priorités déterminent lequel est le plus proche du service d'origine. Deux options règlent ça : within liste les décorateurs qui enveloppent le tien, around liste ceux que le tien enveloppe. Exemple du billet : tu signes chaque requête HTTP sortante, y compris à chaque nouvelle tentative, donc ton décorateur doit se trouver à l'intérieur du RetryableHttpClient :

php
// src/HttpClient/SigningHttpClient.php
namespace App\HttpClient;

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;

#[AsDecorator('http_client', within: RetryableHttpClient::class)]
class SigningHttpClient implements HttpClientInterface
{
    // ...
}
yaml
# config/services.yaml
services:
    App\HttpClient\SigningHttpClient:
        decorates: http_client
        decoration_within: http_client.retryable

Migration Symfony en vue ?

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

Estimer ma migration →

Mêmes règles que pour before et after : identifiants ou noms de classe, seuls ou en liste ; un nom qui ne correspond à aucun autre décorateur du même service est ignoré ; sans priorité, Symfony en choisit une. Ça fonctionne aussi avec #[AsTagDecorator].

Et pour Messenger, Doctrine et les firewalls ?

Messenger. #[AsMessageHandler] et le tag messenger.message_handler acceptent les mêmes options pour ordonner les handlers d'un même message sur un bus. Limite : les contraintes ne valent qu'entre handlers de la même classe de message, pas vers un handler de classe parente, d'interface ou enregistré pour *. Si tes handlers déclenchent des envois fiables, l'outbox de Messenger 8.2 complète ce réglage.

php
// src/MessageHandler/SendWelcomeEmailHandler.php
namespace App\MessageHandler;

use App\Message\UserRegistered;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler(after: AuditLogHandler::class)]
final class SendWelcomeEmailHandler
{
    public function __invoke(UserRegistered $message): void
    {
        // ...
    }
}

Doctrine. Le tag doctrine.event_listener accepte before et after pour ordonner les listeners d'un événement sur une connexion. Le support dans l'attribut #[AsDoctrineListener] passe par DoctrineBundle : le billet le présente comme proposé, et la PR correspondante a été mergée depuis, mais je n'ai pas vérifié dans quelle version il sort.

Firewall. Les bundles qui ajoutent des listeners de firewall, comme l'authentification à deux facteurs, disposent du nouvel attribut #[FirewallListenerOrder]. Ici, la garde est plus stricte : les listeners de firewall ont toujours une priorité, les contraintes ne réordonnent donc que ceux qui la partagent, et une contrainte qui exigerait une autre priorité fait échouer la compilation. Un bundle ne peut pas déplacer par erreur les listeners de sécurité de Symfony.

php
use Symfony\Component\Security\Http\Attribute\FirewallListenerOrder;
use Symfony\Component\Security\Http\Firewall\AbstractListener;
use Symfony\Component\Security\Http\Firewall\AccessListener;
use Symfony\Component\Security\Http\Firewall\ContextListener;

#[FirewallListenerOrder(after: ContextListener::class, before: AccessListener::class)]
final class TwoFactorListener extends AbstractListener
{
    // ...
}

Priorité déclarée ou absente : comment Symfony tranche

Tout repose sur une distinction formalisée par la PR #66012 : une priorité explicite est une revendication, une priorité absente est « indéfinie », pas zéro. Sans priorité, before: LocaleListener::class.'::onKernelRequest' atterrit juste où il faut, rien n'est écrasé. Avec priority: 0 et la même contrainte, le build échoue avec un message qui indique la priorité requise. Pour distinguer « pas de priorité » de 0, AsEventListener::$priority et AsMessageHandler::$priority deviennent ?int, avec null par défaut : à retenir si ton code introspecte ces attributs.

Côté algorithme, la PR écarte le tri de Kahn au profit d'un parcours en profondeur amorcé par l'ordre des priorités : ce qui n'est pas contraint bouge le moins possible. C'est la bonne propriété pour de l'ordre de listeners, où rétrograder un listener tiers que personne n'a contraint serait pire que le mal.

Le filet de sécurité : garder la priorité et ajouter la contrainte

Tu n'es pas obligé de supprimer tes priorités. Le billet propose de les garder et d'ajouter before ou after comme assertion : si une mise à jour change l'ordre, la compilation échoue au lieu de laisser passer une régression. Symfony applique lui-même ce pattern à ses value resolvers : Request, Session et #[CurrentUser] déclarent passer avant l'EntityValueResolver de Doctrine. La PR #66713 explique le risque évité : un argument #[CurrentUser] User $user qui serait chargé depuis la route au lieu du token.

Les pièges à connaître avant de migrer

  • Typo muette. Une cible absente de l’événement est ignorée par conception. Une faute dans un nom de classe désactive donc ta contrainte sans erreur. Seule exception documentée par la PR : si le service existe sur l’événement mais pas la méthode visée, une exception est levée.
  • Mélange priorité et contrainte. Une priorité qui contredit la contrainte fait échouer le build. Dans le doute, retire la priorité et laisse la contrainte la déduire.
  • Cible trop large. Nommer un service vise toutes ses inscriptions sur l’événement ; utilise ::méthode pour viser un seul listener.
  • Bundles qui introspectent. $priority devient nullable sur les attributs : gère null si tu les lis par réflexion.

Les priorités numériques et les formes positionnelles de getSubscribedEvents() continuent de fonctionner : rien n'oblige à tout convertir. Pour vérifier l'ordre effectif de tes handlers Messenger après la migration, les outils décrits dans notre article sur l'observabilité de Messenger avec messenger:show restent utiles.

Ma lecture

Une priorité numérique est un commentaire sans texte : elle dit « ici », jamais « pourquoi ». Avec before, after, within et around, ton code énonce l'intention, et le conteneur la vérifie à chaque compilation. Ma position : pour toute dépendance d'ordre vis-à-vis d'un code que tu ne maîtrises pas (Symfony, un bundle tiers), n'écris plus de nombre, nomme la cible. Garde les priorités pour les cas où seul l'ordre global compte, et documente-les.

  • Fais un grep de priority: et priority= dans src/ et config/.
  • Pour chaque occurrence, retrouve la relation qu’elle exprime. Si personne ne la retrouve, tu tiens un candidat prioritaire.
  • Commence par celles qui visent un composant tiers : ce sont elles qui cassent sur une mise à jour mineure.
  • Ajoute un test sur l’ordre effectif des cibles critiques, puisqu’une faute de frappe ne lève aucune erreur.

Si ton application porte des dizaines de priorités posées au fil des années, le vrai chantier n'est pas d'écrire before ou after. C'est de savoir lesquelles comptent, lesquelles sont mortes, et de prouver qu'aucune ne bouge à la montée de version. C'est le type de travail que Bear Upgrade prend en charge : Rector pour la mécanique, des tests qui verrouillent l'ordre d'exécution, et des Merge Requests prêtes à relire.

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.