Le Compiled Event Dispatcher de Symfony 8.2 remplace les appels addListener() exécutés à chaque requête par un tableau PHP de listeners, généré à la compilation du conteneur et conservé par OPcache. Présenté le 7 octobre 2026 sur le blog officiel, contribution de Nicolas Grekas (PR #66122, #66129, #66131, #66133), il arrive avec Symfony 8.2, attendu en novembre 2026. Tes listeners existants en profitent sans modification.
Le coût de cette optimisation est une dépréciation. Modifier le dispatcher partagé à l'exécution (addListener(), addSubscriber() et leurs équivalents remove) devient déprécié, tout comme l'autowiring de Symfony\Component\EventDispatcher\EventDispatcherInterface. Cet article détaille ce qui change, ce que les chiffres disent vraiment, et ce que tu dois chercher dans ton code avant la sortie. Il prolonge l'article d'hier sur l'ordre relatif des listeners dans Symfony 8.2, qui repose sur la même résolution à la compilation.
Pourquoi le dispatcher actuel coûte à chaque requête
Aujourd'hui, le conteneur compilé construit l'event_dispatcher à chaque requête en appelant addListener() pour chaque listener, chacun enveloppé dans une closure. Il le fait même pour les événements que la requête ne dispatchera jamais. Pourtant, Symfony connaît la liste complète des listeners dès la compilation du conteneur : il refait à chaque requête un travail dont il a déjà le résultat.
Un commentaire de Loïc Faugeron sous le billet résume bien le changement. L'extrait suivant, qu'il présente lui-même comme simplifié, illustre le code généré avant 8.2 ; ce n'est pas le code exact :
// var/cache/.../Container.php (simplified)
function getEventDispatcherService()
{
$dispatcher = new EventDispatcher();
$dispatcher->addListener('kernel.request',
fn () => $this->getOrderListener()->onKernelRequest(...), 32);
$dispatcher->addListener('kernel.response',
fn () => $this->getCorsListener()->onKernelResponse(...), 0);
// ... one addListener() per listener (75 in the article's benchmark)
return $dispatcher;
}Qu’est-ce que le Compiled Event Dispatcher change concrètement ?
En 8.2, le service event_dispatcher est un CompiledEventDispatcher. Il reçoit un tableau d'identifiants de services et de méthodes, dans l'ordre d'exécution, et un service locator pour charger les listeners à la demande. Le conteneur contient de la donnée au lieu de code. Même idée, toujours selon le commentaire de Loïc Faugeron :
// Same idea, but data instead of code
return new CompiledEventDispatcher([
'kernel.request' => [['order_listener', 'onKernelRequest']],
'kernel.response' => [['cors_listener', 'onKernelResponse']],
], $serviceLocator);Selon la PR #66122, cette carte est une expression constante : OPcache la garde immuable en mémoire partagée, et chaque requête n'en fabrique pas sa propre copie. Le comportement ne change pas : un listener n'est instancié que quand il va s'exécuter, et si un listener stoppe la propagation, les suivants ne sont pas créés. Le profiler et debug:event-dispatcher fonctionnent comme avant.
Quels gains mesurés, et que valent-ils ?
Le benchmark de la PR #66122 porte sur une application fonctionnelle SecurityBundle : 75 listeners répartis sur 31 événements. Sur ce banc :
- Création du dispatcher : 33,7 µs → 0,48 µs.
- Mémoire tenue par les closures des listeners : 97 232 octets → 0, soit environ 97 Ko en moins.
- Conteneur entier : 266 181 → 251 267 octets.
- Quatre dispatches : 1,125 µs → 1,000 µs. Le dispatch lui-même n’est pas plus rapide, seule l’initialisation l’est.
Ce sont des microbenchmarks. 33 µs économisées pèsent peu face à un temps de réponse de plusieurs dizaines de millisecondes : ces mesures montrent qu'un coût fixe, payé à chaque requête, devient quasi nul, pas que ton application sera plus rapide d'un montant visible. Une réserve, qui est une inférence et non une mesure publiée : le gain se sent surtout en PHP-FPM classique, où le dispatcher est reconstruit à chaque requête. En worker mode avec FrankenPHP, ce coût était déjà amorti.
La PR signale une condition de rentabilité : le gain disparaît dès que quelque chose mute ou introspecte le dispatcher pendant la requête, car il bascule alors vers un EventDispatcher classique. Symfony a dû corriger son propre code pour en profiter : ExceptionListener::register() ajoutait un listener à chaque requête, ce qui annulait le bénéfice (PR #66119).
Pourquoi addListener() et removeListener() sont dépréciés
Puisque le gain repose sur une carte figée, Symfony 8.2 déprécie ces appels sur le service partagé event_dispatcher :
addListener()addSubscriber()removeListener()removeSubscriber()
Les dépréciations remontent en dev, où le profiler les affiche. La consigne du billet : enregistre tes listeners comme services. Un EventDispatcher que tu instancies toi-même reste librement modifiable ; seul le service partagé est concerné. Le premier endroit où ton équipe le sentira, ce sont les tests fonctionnels qui ajoutent un listener au dispatcher du conteneur pour vérifier qu'un événement a été émis. Symfony a dû convertir ses propres tests. La solution : déclarer ce listener « espion » comme service dans l'environnement de test. L'exemple ci-dessous est illustratif, il ne vient pas du billet :
Qui s’occupe de vos mises à jour ?
Maintenance des versions PHP, framework et dépendances, à partir de 500 €/mois HT.
<?php
// Exemple illustratif : un listener espion déclaré comme service de test
declare(strict_types=1);
namespace App\Tests\Support;
use App\Event\OrderPlaced;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: OrderPlaced::class)]
final class OrderPlacedSpy
{
/** @var list<OrderPlaced> */
public array $received = [];
public function __invoke(OrderPlaced $event): void
{
$this->received[] = $event;
}
}Comment gérer des listeners temporaires avec ScopedEventDispatcher
Pour les besoins temporaires, typiquement un listener actif le temps d'une commande console, 8.2 introduit ScopedEventDispatcher. Il enveloppe le dispatcher partagé et garde pour lui les listeners ajoutés, sans toucher au service global. Seuls les événements dispatchés via le wrapper atteignent ces listeners. Exemple du billet, avec un worker Messenger :
use Symfony\Component\EventDispatcher\ScopedEventDispatcher;
use Symfony\Component\Messenger\EventListener\StopWorkerOnMessageLimitListener;
use Symfony\Component\Messenger\Worker;
$dispatcher = new ScopedEventDispatcher($this->eventDispatcher);
$dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(10));
$worker = new Worker($receivers, $bus, $dispatcher);
$worker->run();Le wrapper appelle les deux jeux de listeners par ordre de priorité, comme un dispatcher unique. Symfony l'utilise lui-même dans messenger:consume et messenger:failed:retry, qui n'ont plus de paires add/remove. Si tes workers relaient des messages critiques, l'outbox de Messenger 8.2 est l'autre nouveauté à regarder sur ce terrain.
Quelle interface autowirer : celle des Contracts ou celle du composant ?
L'autowiring de Symfony\Component\EventDispatcher\EventDispatcherInterface sur le dispatcher partagé est déprécié, car cette interface expose les méthodes de mutation. Si ton service ne fait que dispatcher, change l'import :
- use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class OrderPlacer
{
public function __construct(
private EventDispatcherInterface $dispatcher,
) {
}
// ...
}Pour inspecter les listeners, dans un outil de debug par exemple, autowire la nouvelle Symfony\Contracts\EventDispatcher\ListenerIntrospectionInterface, qui expose getListeners(), getListenerPriority() et hasListeners(). Pour dispatcher et inspecter, combine les deux avec un type intersection :
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\EventDispatcher\ListenerIntrospectionInterface;
public function __construct(
private EventDispatcherInterface&ListenerIntrospectionInterface $dispatcher,
) {
}Nuance importante : l'interface du composant n'est pas dépréciée en tant que type. Elle garde son sens, « un dispatcher que tu possèdes et peux modifier ». Seul l'alias d'autowiring l'est (PR #66131), et sa dépréciation se déclenche à la compilation du conteneur, visible via debug:container --deprecations. Côté versions, la nouvelle interface arrive dans symfony/event-dispatcher-contracts 3.8, que symfony/event-dispatcher 8.2 exige.
Comment migrer, étape par étape
- Lance
bin/console debug:container --deprecationset ouvre le profiler en dev pour lister les dépréciations. - Cherche
addListener(,addSubscriber(,removeListener(etremoveSubscriber(dans ton code, tes bundles internes et tes tests. - Pour chaque occurrence, choisis : listener déclaré comme service (cas général),
ScopedEventDispatcher(besoin temporaire), ou service enregistré dans l’environnement de test. - Remplace l’import de l’interface du composant par celle des Contracts partout où tu ne fais que dispatcher : un changement mécanique, applicable en lot.
- Si une mutation vient d’un bundle tiers, ouvre l’issue maintenant : ce sont ces dépendances qui te bloqueront à la montée de version.
Deux points pour les auteurs de bundles, d'après l'UPGRADE-8.2.md : une compiler pass qui lit les appels addListener() de la définition doit s'exécuter avant la pass de compilation des listeners, et un décorateur du dispatcher ne reçoit plus ces appels à l'exécution. Un bundle qui définit son propre dispatcher sans respecter la forme attendue est simplement laissé de côté : pas de casse, mais pas de gain.
Ma lecture
Je ne vendrais pas ce changement comme une optimisation à annoncer à un client : 33 µs par requête ne se voient pas dans un temps de réponse. Le vrai sujet est la dépréciation. Elle ferme une porte que beaucoup de bases de code laissaient ouverte par commodité : modifier le dispatcher global en cours de route. C'est le bon choix. Un dispatcher dont la carte est figée à la compilation se comporte de façon prévisible, et les listeners installés « en douce » sont une source classique de bugs d'ordre d'exécution. Le coût de migration est faible si tu t'y prends avant la sortie.
Ce genre de dépréciation ne bloque rien aujourd'hui, et c'est justement pour ça qu'elle traîne : elle coûte peu à corriger, mais elle s'accumule version après version jusqu'à la majeure suivante. Si personne chez toi n'a le temps d'éplucher les logs de dépréciation à chaque version mineure, c'est le périmètre de la maintenance Bear Upgrade : suivre les versions de PHP, de Symfony et des dépendances, et livrer les correctifs en Merge Requests testées.

É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