Image de couverture : Compiler Passes Symfony : en finir avec les services.yaml de 300 lignes
tech

Compiler Passes Symfony : en finir avec les services.yaml de 300 lignes

27 May 2026
5 min de lecture
8 vues
Sébastien Muler

Compiler Passes Symfony : en finir avec les services.yaml de 300 lignes

Vous reprenez un projet Symfony. Le fichier services.yaml fait 340 lignes. La moitié enregistre des handlers. La documentation d'onboarding contient une section intitulée « n'oubliez pas d'enregistrer votre handler ». Et inévitablement, quelqu'un l'oublie — et découvre l'erreur à 3h du matin avec une 500.

Ce projet souffre d'un Compiler Pass manquant. Une fois en place, vous écrivez votre classe handler, et le conteneur la trouve tout seul. Aucune modification de YAML. Et si le handler est mal formé, le build échoue avec un message d'erreur clair, avant même d'atteindre la production.

Les Compiler Passes sont la fonctionnalité qui distingue silencieusement les développeurs Symfony seniors des autres. Voici comment ils fonctionnent réellement.

Comment fonctionne le conteneur Symfony en deux phases

Le conteneur Symfony opère en deux temps distincts :

  • Build-time : Symfony lit votre configuration, résout les arguments, valide l'ensemble, puis génère une classe PHP compilée dans var/cache/.
  • Runtime : c'est cette classe compilée que votre application utilise — de simples appels de méthodes, ultra-rapides.

Un Compiler Pass est un hook dans la phase de build. Vous accédez au ContainerBuilder brut avant qu'il soit compilé. Vous pouvez lire les définitions, en ajouter, en modifier, et câbler des services ensemble de façon dynamique.

La classe de base à implémenter :

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class RegisterHandlersPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        // Votre logique de câblage ici
    }
}

Ce process() est appelé une seule fois, au build. Pas de surcoût à l'exécution.

Le pattern concret : auto-enregistrement des handlers

Imaginez un bus de commandes maison. Sans Compiler Pass, votre services.yaml ressemble à ça :

services:
  App\Handler\CreateUserHandler:
    tags:
      - { name: 'app.command_handler' }

  App\Handler\DeleteUserHandler:
    tags:
      - { name: 'app.command_handler' }

  App\Handler\UpdateUserHandler:
    tags:
      - { name: 'app.command_handler' }
# ... 40 handlers de plus

Avec un Compiler Pass, vous remplacez tout ça par une logique automatique basée sur un tag.

D'abord, configurez l'autoconfiguration dans services.yaml :

services:
  _instanceof:
    App\Contract\CommandHandlerInterface:
      tags: ['app.command_handler']

Ensuite, le Compiler Pass collecte tous les services tagués et les injecte dans votre bus :

class RegisterHandlersPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        if (!$container->has(CommandBus::class)) {
            return;
        }

        $definition = $container->findDefinition(CommandBus::class);
        $handlers = $container->findTaggedServiceIds('app.command_handler');

        foreach ($handlers as $id => $tags) {
            $definition->addMethodCall('register', [new Reference($id)]);
        }
    }
}

Enfin, enregistrez le pass dans votre Kernel ou votre bundle :

class AppKernel extends Kernel
{
    protected function build(ContainerBuilder $container): void
    {
        $container->addCompilerPass(new RegisterHandlersPass());
    }
}

Résultat : créez un nouveau handler qui implémente CommandHandlerInterface, et il est automatiquement enregistré au prochain cache:clear. Aucune modification de configuration.

Validation au build plutôt qu'en production

L'autre avantage majeur : vous pouvez valider vos contraintes à la compilation. Si un handler n'implémente pas la bonne interface ou manque d'une méthode requise, le build échoue avec un message explicite.

foreach ($handlers as $id => $tags) {
    $handlerClass = $container->findDefinition($id)->getClass();

    if (!is_subclass_of($handlerClass, CommandHandlerInterface::class)) {
        throw new InvalidArgumentException(
            sprintf('Le service "%s" est tagué app.command_handler mais n\'implémente pas CommandHandlerInterface.', $id)
        );
    }

    $definition->addMethodCall('register', [new Reference($id)]);
}

Cette erreur apparaît lors du cache:warmup en CI, pas lors d'une requête en production. C'est la différence entre un bug découvert par un développeur et un bug découvert par un utilisateur.

Quand utiliser un Compiler Pass ?

Ce mécanisme est particulièrement adapté dans ces situations :

  • Enregistrement de handlers : commandes, événements, requêtes — tout pattern où N classes s'enregistrent dans un dispatcher.
  • Plugins et extensions : permettre à des bundles tiers de s'insérer dans votre système sans modifier le code core.
  • Validation d'architecture : vérifier à la compilation que vos règles de couplage sont respectées (ex. : aucun service de domaine ne dépend d'un service d'infrastructure).
  • Décoration conditionnelle : envelopper automatiquement des services avec des decorators selon leur tag ou leur classe.

Symfony lui-même utilise massivement ce mécanisme en interne — le RouterPass, le TranslatorPass, le câblage des voters de sécurité — tout cela passe par des Compiler Passes.

Conclusion

Les Compiler Passes ne sont pas une fonctionnalité avancée réservée aux frameworks internes. Ce sont un outil quotidien pour quiconque maintient une application Symfony qui évolue.

Un services.yaml de 300 lignes n'est pas une fatalité. C'est le symptôme d'une automatisation manquante. Avec un Compiler Pass bien placé, vous supprimez la configuration répétitive, vous déplacez la détection d'erreurs du runtime vers le build, et vous rendez votre architecture extensible par convention plutôt que par configuration explicite.

La prochaine fois que vous écrivez « n'oubliez pas d'enregistrer X dans Y », demandez-vous si un Compiler Pass ne pourrait pas rendre cette instruction inutile.


Cet article s'inspire de The Symfony DI Compiler Pass Every Senior Dev Should Know par Gabriel Anhaia sur DEV Community.

Partager cet article