Ouvre n'importe quelle base de code Symfony un peu mûre et fais un grep sur onKernelResponse. Je te parie un café que tu trouves au moins un listener qui se déclenche à chaque requête, fouille dans $request->attributes à la recherche d'une clé à underscore à moitié documentée, et s'échappe par un early return quatre-vingt-dix-huit pour cent du temps. Ce pattern n'a jamais été l'idée que quiconque se faisait du bon design. C'était juste la seule façon de rendre un attribut de contrôleur maison utile sur tout le cycle de vie de la requête, parce que le framework lui-même ne t'offrait rien de mieux. Symfony 8.1 supprime cette excuse, et je pense que ça change discrètement la manière dont on devrait structurer le code transversal dans nos applis.

Le défaut sous-jacent était presque ridiculement petit. Les attributs étaient résolus pendant kernel.controller puis rangés sur l'objet ControllerEvent lui-même. ControllerArgumentsEvent pouvait encore les voir via une référence arrière, mais au moment où ResponseEvent arrivait, ils avaient disparu. Le CacheAttributeListener du framework lui-même devait faire passer en contrebande les données de #[Cache] via une clé privée _cache sur la requête, juste pour poser des en-têtes plus tard. Quand le cœur du framework a besoin d'un contournement pour sa propre API, tout le monde en aval copie ce contournement avec ses propres noms de clés, et c'est exactement ce qui s'est passé dans la nature.

Le correctif de la 8.1 est ennuyeux dans le meilleur sens du terme : les attributs vivent désormais sur la Request sous _controller_attributes, et comme chaque événement du kernel transporte déjà la même instance de Request, ils sont lisibles à n'importe quel moment du cycle de vie, chemin d'exception compris. Il y a même du ménage prévu pour le cas limite où setController() remplace le contrôleur en plein vol, si bien que tu ne lis jamais les attributs d'un contrôleur qui n'est plus celui qui s'exécute. Aucune astuce, juste de l'état déplacé vers l'objet qui survit réellement à toute la requête. La plupart des bugs de framework que j'ai traqués dans ma carrière se résumaient à de l'état logé sur le mauvais objet, alors j'apprécie une note de version qui est en substance un aveu de ça.

Le second changement est celui qui va remodeler le code du quotidien. Un nouveau ControllerAttributesListener dispatche un sous-événement dédié par instance d'attribut, nommé en composant l'événement du kernel avec le FQCN de l'attribut : kernel.response plus Cache::class devient donc son propre événement auquel s'abonner. Ton listener s'enregistre pour ce nom composé et reçoit un ControllerAttributeEvent portant l'instance typée de l'attribut ainsi que l'événement kernel d'origine. Pas de vérification de présence, pas de pêche dans un tableau, pas d'invocation du tout quand l'attribut n'est pas là. Le conteneur sait à la compilation quels événements d'attributs ont des abonnés, donc rien de spéculatif n'est dispatché. Si tu as déjà profilé une requête en regardant une douzaine de listeners se réveiller, jeter un œil autour d'eux et se rendormir, tu sais pourquoi ça compte au-delà de l'esthétique.

Maintenant la concession honnête, parce qu'il y en a une. Les noms d'événements composés sont des chaînes collées avec un point, et le dispatch typé façon chaîne de caractères, c'est exactement le genre de magie qui fait qu'un nouveau collègue fixe le profiler en se demandant pourquoi son listener ne se déclenche jamais. Les règles de cycle de vie ajoutent aussi de la friction : avant l'exécution du contrôleur tu appelles getAttributes() sur l'événement kernel, après tu passes par controllerMetadata, et appeler le mauvais pour ton étape est une erreur fatale plutôt qu'un null. Le schéma de priorités, -10000 sur les événements de contrôleur et 10000 sur les suivants, est délibéré et correct, mais c'est un contrat invisible de plus dont tu dois connaître l'existence. Si ton équipe peine déjà à tracer le flux d'événements, ça ajoute une couche.

Je reste fermement du côté pour, pour une raison : l'alternative était pire et on la vivait tous. L'ancien monde avait la même magie, mais faite maison, non documentée, et différente dans chaque projet. Une clé de requête privée inventée par un développeur en 2022 est bien plus hostile pour un nouveau venu qu'une convention au niveau du framework que debug:event-dispatcher peut introspecter. Et le mécanisme a fait ses preuves, puisque #[Cache] et #[RateLimit] tournent désormais exactement sur ce pipeline en interne. Quand le cœur du framework mange sa propre cuisine, je fais davantage confiance à la recette.

Mon conseil pratique, c'est donc de traiter ça comme une invitation à refactoriser, pas juste comme une ligne de changelog. Ce listener qui vérifie si une route a besoin d'une entrée dans le journal d'audit, ce truc façon middleware qui impose l'isolation des tenants, ce décorateur de réponse qui ajoute des en-têtes de dépréciation aux vieilles versions d'API : tous supplient de devenir un attribut plus un listener ciblé. Mais je sais aussi où cette route peut mener, parce que j'ai vu des contrôleurs porter six attributs comme un général porte ses médailles, et à un moment, déclaratif cesse de vouloir dire lisible. Alors dis-moi, cher collègue : quel est le premier listener kernel générique que tu convertirais dans ta base de code, et combien d'attributs sur une seule action constituent ton seuil de douleur personnel avant de repousser la logique dans de simples services ?