Compiler Passes — Manipuler le container Symfony avant compilation
TL;DR — Le container de services Symfony n'est pas magique : c'est le résultat d'une chaîne de compiler passes qui transforment une collection de
Definitionbrutes en un container PHP optimisé et figé. Comprendre cette chaîne (Optimization, Removing, AfterRemoving), savoir y insérer ses propres passes, manipuler lestagged_iterator, décorer des services existants, et debugger viadebug:container --env=prodest ce qui sépare un développeur Symfony intermédiaire d'un architecte capable d'écrire des bundles tiers et des systèmes de plugins propres.
🧠 Mental model — ASCII + analogie
Le container Symfony est une chaîne de montage industrielle. Vos fichiers services.yaml, vos attributs #[AsService] et vos extensions de bundles ne sont que des bons de commande. Le moteur de compilation lit ces bons, les transforme via une série de postes de travail (les compiler passes), puis fige le tout dans un fichier PHP optimisé (var/cache/prod/App_KernelProdContainer.php).
┌─────────────────────────────────────────────────────────┐
│ PHASE 1 : LOAD │
│ services.yaml + Extensions + #[AsService] + Autowire │
│ → ContainerBuilder (brouillon) │
└─────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────────┐
│ PHASE 2 : COMPILATION (passes) │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌─────────────────┐ │
│ │ BeforeOptim. │ → │ Optimization │ → │ BeforeRemoving │ │
│ │ (vos passes, │ │ (autowire, │ │ (dernière │ │
│ │ tag wiring) │ │ résolution, │ │ manipulation │ │
│ │ │ │ alias, refs) │ │ avant cleanup) │ │
│ └───────────────┘ └───────────────┘ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ AfterRemoving │ ← │ Removing │ │
│ │ (cleanup, │ │ (services │ │
│ │ validation) │ │ inutilisés │ │
│ └───────────────┘ │ élagués) │ │
│ └───────────────┘ │
└───────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ PHASE 3 : DUMP │
│ PHP compilé, immutable, chargé en prod (OPcache) │
└─────────────────────────────────────────────────────────┘⚠️ Précision : Symfony a exactement cinq phases de passes, dans cet ordre :
BeforeOptimization→Optimization→BeforeRemoving→Removing→AfterRemoving. Il n'y a pas de phase « AfterOptimization » distincte (c'estBeforeRemovingqui joue ce rôle). Vos passes maison vont presque toujours enBeforeOptimization(la valeur par défaut deaddCompilerPass()), car à ce stade lesDefinitionbrutes sont encore manipulables. Le tableau de référence ci-dessous donne la sémantique exacte de chaque phase.
Phase (PassConfig::TYPE_*) | Constante | Quand ? | Ce qui s'y passe / ce que vous y faites |
|---|---|---|---|
| BeforeOptimization | TYPE_BEFORE_OPTIMIZATION | Tout début, avant autowiring | 99 % de vos passes : wiring de tags, ajout de Definition, décoration manuelle. Les arguments ne sont pas encore résolus. |
| Optimization | TYPE_OPTIMIZE | Cœur du moteur | Symfony résout l'autowiring, les alias, les ChildDefinition, inline les références. Y mettre un pass = lire/écrire après résolution (rare, avancé). |
| BeforeRemoving | TYPE_BEFORE_REMOVING | Avant l'élagage | Dernière fenêtre pour agir sur des services qui vont peut-être être supprimés. |
| Removing | TYPE_REMOVE | Élagage | Symfony supprime les services privés non référencés (InlineServiceDefinitionsPass, RemoveUnusedDefinitionsPass). |
| AfterRemoving | TYPE_AFTER_REMOVING | Tout à la fin | Validation finale, cleanup. Le container est quasi figé : ne plus ajouter de service ici. |
L'analogie : voyez les services.yaml comme un plan d'architecte brut. Les compiler passes sont les équipes spécialisées sur le chantier : électriciens (autowire), plombiers (résolution des arguments), peintres (tags), inspecteurs (validation). Une fois le bâtiment livré (container compilé), plus rien ne bouge — sauf reconstruction complète (cache:clear). C'est précisément ce qui rend Symfony rapide en production : tout le travail coûteux (résolution, validation, optimisation) est fait une seule fois, à la compilation.
Comment un staff engineer raisonne : la première question n'est jamais « comment j'écris un compiler pass », mais « ai-je vraiment besoin d'un pass ? ». Le moteur de DI offre une échelle d'outils, du moins coûteux au plus puissant : (1) autowiring simple, (2) _instanceof + tagged_iterator/tagged_locator, (3) attributs #[AutowireIterator]/#[AutowireLocator], (4) registerForAutoconfiguration(), et seulement en dernier recours (5) un CompilerPassInterface maison. Chaque marche ajoute du couplage au moteur de compilation et de la surface de debug. On ne grimpe à la marche 5 que lorsqu'on doit transformer des définitions (tri topologique, validation cross-service, décoration conditionnelle, élagage) — pas seulement les collecter.
🛠️ Code minimal (PHP 8.2+)
Un compiler pass simple : collecter des services tagués
Imaginons un système de handlers de notifications où chaque handler s'enregistre via un tag.
<?php
// src/Notification/NotificationHandlerInterface.php
declare(strict_types=1);
namespace App\Notification;
interface NotificationHandlerInterface
{
public function supports(string $channel): bool;
public function send(string $recipient, string $message): void;
}<?php
// src/Notification/EmailHandler.php
declare(strict_types=1);
namespace App\Notification;
final class EmailHandler implements NotificationHandlerInterface
{
public function supports(string $channel): bool
{
return $channel === 'email';
}
public function send(string $recipient, string $message): void
{
// ... envoi SMTP
}
}<?php
// src/Notification/NotificationDispatcher.php
declare(strict_types=1);
namespace App\Notification;
final class NotificationDispatcher
{
/** @param iterable<NotificationHandlerInterface> $handlers */
public function __construct(private readonly iterable $handlers) {}
public function dispatch(string $channel, string $recipient, string $message): void
{
foreach ($this->handlers as $handler) {
if ($handler->supports($channel)) {
$handler->send($recipient, $message);
return;
}
}
throw new \RuntimeException("No handler supports channel '{$channel}'");
}
}Le compiler pass : injection des handlers tagués
<?php
// src/DependencyInjection/Compiler/NotificationHandlerPass.php
declare(strict_types=1);
namespace App\DependencyInjection\Compiler;
use App\Notification\NotificationDispatcher;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
final class NotificationHandlerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition(NotificationDispatcher::class)) {
return;
}
$definition = $container->getDefinition(NotificationDispatcher::class);
$taggedServices = $container->findTaggedServiceIds('app.notification_handler');
$references = [];
foreach ($taggedServices as $id => $tags) {
// priority éventuelle via attribut de tag
$priority = $tags[0]['priority'] ?? 0;
$references[$priority][] = new Reference($id);
}
// tri par priorité décroissante
krsort($references);
$flat = array_merge(...$references);
$definition->setArgument('$handlers', $flat);
}
}Enregistrement du pass dans le Kernel
<?php
// src/Kernel.php
declare(strict_types=1);
namespace App;
use App\DependencyInjection\Compiler\NotificationHandlerPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
final class Kernel extends BaseKernel
{
use MicroKernelTrait;
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(
new NotificationHandlerPass(),
PassConfig::TYPE_BEFORE_OPTIMIZATION,
priority: 0
);
}
}Auto-tagging via _instanceof (la méthode moderne)
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
_instanceof:
App\Notification\NotificationHandlerInterface:
tags: ['app.notification_handler']
App\:
resource: '../src/'Avec autoconfigure, vous pouvez aussi utiliser un attribut PHP :
<?php
// src/Kernel.php — version avec autoconfiguration
protected function build(ContainerBuilder $container): void
{
$container->registerForAutoconfiguration(NotificationHandlerInterface::class)
->addTag('app.notification_handler');
$container->addCompilerPass(new NotificationHandlerPass());
}Variante moderne : tagged_iterator sans compiler pass
Depuis Symfony 4.3+, on peut éviter le compiler pass pour le cas simple grâce à tagged_iterator :
# config/services.yaml
services:
App\Notification\NotificationDispatcher:
arguments:
$handlers: !tagged_iterator { tag: 'app.notification_handler', default_priority_method: 'getPriority' }Ou en PHP DSL :
<?php
// config/services.php
use App\Notification\NotificationDispatcher;
use App\Notification\NotificationHandlerInterface;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $c): void {
$services = $c->services()
->defaults()->autowire()->autoconfigure();
$services->instanceof(NotificationHandlerInterface::class)
->tag('app.notification_handler');
$services->set(NotificationDispatcher::class)
->args([tagged_iterator('app.notification_handler')]);
};Le tagged_iterator est paresseux : Symfony injecte un RewindableGenerator qui n'instancie les handlers qu'au moment de l'itération.
🎯 Patterns courants
1. Plugin system — chargement dynamique via tags
C'est exactement ce que fait EventDispatcher en interne. Chaque listener est un service tagué kernel.event_listener ou kernel.event_subscriber, et RegisterListenersPass les agrège dans le dispatcher.
<?php
// src/DependencyInjection/Compiler/PluginPass.php
final class PluginPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$registry = $container->getDefinition('app.plugin_registry');
foreach ($container->findTaggedServiceIds('app.plugin') as $id => $tags) {
foreach ($tags as $attributes) {
$registry->addMethodCall('register', [
$attributes['name'] ?? $id,
new Reference($id),
$attributes['version'] ?? '1.0',
]);
}
}
}
}2. Service decoration — wrapper transparent
La décoration permet de remplacer un service par un wrapper sans toucher le code consommateur. Utilisée pour le caching, le logging, le mocking en tests.
<?php
// src/Cache/CachingUserRepository.php
declare(strict_types=1);
namespace App\Cache;
use App\Repository\UserRepository;
use Symfony\Contracts\Cache\CacheInterface;
final class CachingUserRepository extends UserRepository
{
public function __construct(
private readonly UserRepository $inner,
private readonly CacheInterface $cache,
) {}
public function find(int $id): ?User
{
return $this->cache->get("user.{$id}", fn () => $this->inner->find($id));
}
}# config/services.yaml
services:
App\Cache\CachingUserRepository:
decorates: App\Repository\UserRepository
arguments:
$inner: '@.inner'
$cache: '@cache.app'L'attribut .inner (anciennement App\Cache\CachingUserRepository.inner puis .inner depuis Symfony 5.1) référence le service original. En PHP attributes (Symfony 6.1+) :
<?php
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\MapDecorated;
#[AsDecorator(decorates: UserRepository::class)]
final class CachingUserRepository extends UserRepository
{
public function __construct(
#[MapDecorated] private readonly UserRepository $inner,
private readonly CacheInterface $cache,
) {}
}3. Registry pattern — accès locator-based
Quand vous avez besoin d'accéder à un service par clé dynamique (ex. selon une valeur en base), un ServiceLocator est plus performant qu'un iterable car il n'instancie que le service demandé.
<?php
// src/DependencyInjection/Compiler/StrategyRegistryPass.php
final class StrategyRegistryPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$services = [];
foreach ($container->findTaggedServiceIds('app.payment_strategy') as $id => $tags) {
$key = $tags[0]['key'] ?? throw new \LogicException("Tag 'key' missing on {$id}");
$services[$key] = new Reference($id);
}
$locator = ServiceLocatorTagPass::register($container, $services);
$container->getDefinition('app.payment_strategy_registry')
->setArgument('$locator', $locator);
}
}Le ServiceLocator instancie un service à la fois, à la demande — idéal pour des dizaines de stratégies.
4. Conditional services — selon bundle ou paramètre
<?php
final class OptionalIntegrationPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
// Active un service uniquement si DoctrineBundle est présent
$bundles = $container->getParameter('kernel.bundles');
if (!isset($bundles['DoctrineBundle'])) {
$container->removeDefinition('app.doctrine_audit_listener');
return;
}
// Ajoute un alias conditionnel
if ($container->getParameter('app.feature.audit_enabled')) {
$container->setAlias(
'app.audit_logger',
$container->getParameter('app.audit_implementation')
);
}
}
}5. Bundle Extension + Configuration — DSL maison
Pour distribuer un bundle réutilisable, on combine une Configuration (validation YAML), une Extension (chargement) et éventuellement des passes.
<?php
// src/DependencyInjection/AppExtension.php
namespace App\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
final class AppExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$container->setParameter('app.api_key', $config['api_key']);
$container->setParameter('app.timeout', $config['timeout']);
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../../config'));
$loader->load('services.yaml');
if ($config['cache']['enabled']) {
$loader->load('cache.yaml');
}
}
}<?php
// src/DependencyInjection/Configuration.php
namespace App\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
final class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('app');
$root = $treeBuilder->getRootNode();
$root
->children()
->scalarNode('api_key')->isRequired()->cannotBeEmpty()->end()
->integerNode('timeout')->defaultValue(30)->min(1)->max(300)->end()
->arrayNode('cache')
->addDefaultsIfNotSet()
->children()
->booleanNode('enabled')->defaultTrue()->end()
->scalarNode('ttl')->defaultValue('1 hour')->end()
->end()
->end()
->end();
return $treeBuilder;
}
}6. Cas réel — EventDispatcher dans Symfony core
Le composant EventDispatcher (FrameworkBundle) utilise RegisterListenersPass pour collecter tous les services tagués kernel.event_listener et kernel.event_subscriber. Pour chaque service, le pass :
- Lit le tag (event name, method, priority).
- Récupère la
Definitiondu dispatcher. - Ajoute un
addListener()viaaddMethodCall()— pas une instanciation directe, ce qui préserve la lazy-loading. - Supprime les tags traités pour éviter une double-registration.
C'est exactement le pattern à reproduire dans vos bundles : Definition::addMethodCall() plutôt qu'instancier.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x + libs
| Version | Apport notable |
|---|---|
| Symfony 4.3 | tagged_iterator (lazy), _instanceof dans YAML. |
| Symfony 5.1 | Alias .inner simplifié pour la décoration. |
| Symfony 5.3 | #[Autowire], #[AutowireDecorated] attributs PHP. |
| Symfony 5.4 LTS | Stable, base recommandée pour bundles tiers. |
| Symfony 6.1 | #[AsDecorator], #[MapDecorated], #[TaggedIterator], #[TaggedLocator], #[AsTaggedItem]. |
| Symfony 6.2 | #[AutowireLocator], #[AutowireIterator]. |
| Symfony 6.3 | #[Exclude] pour exclure des classes du autodiscovery. |
| Symfony 6.4 LTS | Tous les attributs PHP stabilisés, recommandé en production. |
| Symfony 7.0 | Suppression d'aliases de méthodes dépréciés, addCompilerPass() strictement typé. |
| Symfony 7.1+ | ChildDefinition améliorée, meilleur support des unions/intersections en autowiring. |
| Symfony 7.2 | Optimisations du dump pour réduire la taille du container compilé. |
Sur les bundles tiers, viser Symfony 6.4 + 7.x est aujourd'hui la norme. PHP 8.2+ permet d'utiliser pleinement les readonly properties et les enums dans les Configurations.
⚠️ Pitfalls — 6 à 10 pièges réels
Oublier la phase de pass.
TYPE_BEFORE_OPTIMIZATIONest la valeur par défaut. Si vous voulez agir après l'autowiring (ex. lire les arguments résolus), utilisezTYPE_OPTIMIZE. Pour nettoyer après suppression des services inutilisés,TYPE_AFTER_REMOVING.Manipuler des services synthetic. Un service
synthetic: true(ex.kernel) n'a pas de définition complète à la compilation : tester avechasDefinition()AVANT d'appelergetDefinition(), sinonServiceNotFoundException.Instancier au lieu de référencer. Appeler
new MonHandler()dans un pass est tentant mais catastrophique : vous perdez l'autowiring, la lazy-loading et le service n'apparaît pas dansdebug:container. Toujours passer parDefinition+Reference.Tags non supprimés. Si plusieurs passes consomment le même tag, le second risque de retraiter les services déjà enregistrés. Appeler
$container->getDefinition($id)->clearTag('mon_tag')après traitement. (RegisterListenersPassle fait.)Priorité oubliée. L'ordre d'exécution des passes au sein d'une même phase dépend de la priorité (haute → tôt). Mal réglé, vous lisez des définitions pas encore résolues. En cas de doute :
bin/console debug:container --env=prod --typespuis tester.Modifier le container après compilation. Une fois compilé, le container est figé. Toute modification (alias dynamique, ajout de service) doit passer par un pass ou un kernel reboot. Cause classique de bugs en tests :
$container->set()ne fonctionne que si le service estsynthetic.Auto-configuration vs tag explicite.
_instanceofouregisterForAutoconfiguration()ne marche pas pour les services importés depuis un autre bundle (qui les déclare déjà). Toujours préférer un tag explicite si vous écrivez un bundle distribuable.findTaggedServiceIds()retourne aussi les attributs. Le tableau est[serviceId => [['priority' => 10, 'event' => 'foo'], ...]]. Un service peut avoir le même tag plusieurs fois avec différents attributs — c'est ce qu'utilisent les event subscribers.Décoration en chaîne mal ordonnée. Si deux décorations s'appliquent au même service, la
prioritydu décorateur compte (decoration_priority). Par défaut, le plus récemment chargé est le plus externe — pas toujours désiré.Compiler passes lents. Un pass qui parcourt tous les services (sans filtrage par tag) peut ajouter 100-500 ms à
cache:warmup. Toujours filtrer viafindTaggedServiceIds()ouhasDefinition()plutôt qu'itérer surgetDefinitions().
🧪 Testing
Test unitaire d'un compiler pass
<?php
// tests/DependencyInjection/Compiler/NotificationHandlerPassTest.php
declare(strict_types=1);
namespace App\Tests\DependencyInjection\Compiler;
use App\DependencyInjection\Compiler\NotificationHandlerPass;
use App\Notification\NotificationDispatcher;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
final class NotificationHandlerPassTest extends TestCase
{
public function testHandlersAreInjectedByPriority(): void
{
$container = new ContainerBuilder();
$container->setDefinition(
NotificationDispatcher::class,
new Definition(NotificationDispatcher::class)
);
$container->setDefinition('handler.email', (new Definition())
->addTag('app.notification_handler', ['priority' => 10]));
$container->setDefinition('handler.sms', (new Definition())
->addTag('app.notification_handler', ['priority' => 100]));
(new NotificationHandlerPass())->process($container);
$args = $container->getDefinition(NotificationDispatcher::class)->getArguments();
$refs = $args['$handlers'];
self::assertCount(2, $refs);
self::assertSame('handler.sms', (string) $refs[0]);
self::assertSame('handler.email', (string) $refs[1]);
}
public function testNoHandlerDoesNotCrash(): void
{
$container = new ContainerBuilder();
$container->setDefinition(
NotificationDispatcher::class,
new Definition(NotificationDispatcher::class)
);
(new NotificationHandlerPass())->process($container);
$args = $container->getDefinition(NotificationDispatcher::class)->getArguments();
self::assertSame([], $args['$handlers'] ?? []);
}
}Test d'intégration via KernelTestCase
<?php
// tests/Integration/ContainerCompilationTest.php
declare(strict_types=1);
namespace App\Tests\Integration;
use App\Notification\NotificationDispatcher;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class ContainerCompilationTest extends KernelTestCase
{
public function testDispatcherHasHandlers(): void
{
self::bootKernel();
$dispatcher = self::getContainer()->get(NotificationDispatcher::class);
$reflection = new \ReflectionProperty($dispatcher, 'handlers');
$handlers = iterator_to_array($reflection->getValue($dispatcher));
self::assertNotEmpty($handlers);
}
}Debug du container compilé
# Liste tous les services
bin/console debug:container --env=prod
# Cherche un service précis
bin/console debug:container App\\Notification --env=prod
# Liste tous les services tagués
bin/console debug:container --tag=app.notification_handler --env=prod
# Affiche les paramètres
bin/console debug:container --parameters --env=prod
# Audite l'autowiring sur une classe
bin/console debug:autowiring NotificationHandlerInterface --env=prod
# Inspecte le container compilé brut
ls -lh var/cache/prod/App_KernelProdContainer.*Pour un debug très avancé, ouvrir var/cache/dev/App_KernelDevContainer.php (ou .prod.) et regarder les protected function getXxxService() — c'est exactement le code généré qui sera exécuté.
🎬 Cas d'usage concrets
Plugin system SaaS RH
Une plateforme SaaS RH propose un cœur fonctionnel (paie, congés, notes de frais) extensible par des plugins développés par des partenaires ou par les clients eux-mêmes : module reporting comptable spécifique, intégration SIRH externe, calculateur de prime selon convention collective, connecteur signature électronique. Le défi : permettre à un plugin de s'enregistrer dans le système central sans modifier le code applicatif, tout en imposant un contrat clair (interface PluginInterface, métadonnées de version, dépendances). La solution s'appuie sur un compiler pass PluginRegistryPass. Chaque plugin est packagé en bundle Symfony qui déclare ses services taggés app.hr_plugin avec attributs (name, version, min_core_version, provides). À la compilation du container, le pass scanne tous les services taggés, vérifie la compatibilité de version avec le cœur, résout les dépendances entre plugins (un plugin "fiche de paie spéciale Syntec" peut dépendre du plugin "convention collective Syntec"), trie topologiquement et injecte la liste ordonnée dans le PluginRegistry. Si un plugin déclare un conflit ou une incompatibilité, une LogicException est levée à la compilation — fail fast avant même que l'app ne démarre. Bonus : un compiler pass secondaire PluginRoutingPass collecte les routes déclarées par chaque plugin et les enregistre dans le router central, permettant à chaque plugin d'ajouter ses propres URLs (/plugins/syntec/payroll/...) sans toucher au routes.yaml du cœur. Cette architecture a permis de passer de 4 à 47 plugins en 18 mois, avec une charge de maintenance constante côté équipe cœur.
Registry pattern e-commerce stratégies de prix
Une marketplace e-commerce gère des dizaines de stratégies de calcul de prix selon le contexte : prix B2C standard, prix B2B avec remise par tranche, prix promo limité dans le temps, prix bundle (achat de 3 produits ensemble), prix fidélité (selon nombre de commandes), prix dynamique (selon stock et demande), prix négocié par compte enterprise. Chaque stratégie est encapsulée dans une classe implémentant PricingStrategyInterface avec une méthode supports(PricingContext) et compute(PricingContext): Price. Plutôt que d'avoir un gros switch dans un service "moteur de prix", l'équipe a opté pour un Registry alimenté par un compiler pass. Chaque stratégie est taguée app.pricing_strategy avec une priority (les plus spécifiques en premier : la stratégie compte enterprise prime sur la stratégie standard B2B). Le compiler pass PricingStrategyPass collecte les stratégies tagguées, les trie par priorité descendante, et les injecte sous forme d'iterable dans le PricingEngine. Au calcul d'un prix, le moteur itère sur les stratégies dans l'ordre et utilise la première qui supporte le contexte. Avantages : ajout d'une nouvelle stratégie = créer une classe + tag, zéro modification du moteur ; tests unitaires triviaux (chaque stratégie est testée isolément) ; performance optimale via tagged_iterator lazy (les stratégies non utilisées ne sont jamais instanciées). L'équipe a migré 12 stratégies hardcodées vers cette architecture en 2 jours et continue d'en ajouter régulièrement.
Dynamic strategies banque calculs réglementaires
Une banque doit calculer des indicateurs réglementaires en continu : ratio de liquidité LCR, ratio de solvabilité CRR, exigences MIFID II par produit, calculs de risque pour Bâle III. Chaque indicateur a sa propre logique métier, peut dépendre de versions réglementaires différentes selon la juridiction (France, Allemagne, Italie pour les filiales européennes), et doit pouvoir être désactivé/activé selon le contexte client. La solution combine plusieurs compiler passes. Le pass RegulatoryCalculatorPass collecte les services taggués bank.regulatory_calculator avec attributs jurisdiction (FR/DE/IT/EU), regulation (LCR/CRR/MIFID2/Basel3), version (date d'entrée en vigueur). Le pass construit un ServiceLocator indexé par {jurisdiction}-{regulation} permettant au moteur central de récupérer le bon calculateur en O(1). Un second pass RegulatoryActivationPass lit la configuration tenant (table en DB chargée au build) et désactive les calculateurs non pertinents pour le tenant courant via removeDefinition(). Un troisième pass RegulatoryAuditPass injecte automatiquement un décorateur d'audit autour de chaque calculateur (capture des inputs, outputs, durée d'exécution, signature des données pour la traçabilité réglementaire). Lors d'un changement de réglementation (nouvelle version LCR), il suffit d'ajouter une nouvelle classe taggée avec la nouvelle date — l'ancien calculateur reste disponible pour les calculs historiques. Cette architecture a passé deux audits ACPR sans aucune remarque sur l'auditabilité du code.
🛠️ Exemple end-to-end
Système de plugins SaaS RH avec compiler pass, vérification de compatibilité, tri topologique des dépendances et injection dans un registry.
<?php
// src/Plugin/PluginInterface.php
declare(strict_types=1);
namespace App\Plugin;
interface PluginInterface
{
public function getName(): string;
public function getVersion(): string;
public function boot(): void;
}<?php
// src/Plugin/PluginRegistry.php
declare(strict_types=1);
namespace App\Plugin;
use Psr\Log\LoggerInterface;
final class PluginRegistry
{
/** @var array<string, PluginInterface> */
private array $plugins = [];
private bool $booted = false;
public function __construct(
/** @var iterable<PluginInterface> */
private readonly iterable $taggedPlugins,
private readonly LoggerInterface $logger,
) {}
public function bootAll(): void
{
if ($this->booted) {
return;
}
foreach ($this->taggedPlugins as $plugin) {
$this->plugins[$plugin->getName()] = $plugin;
$plugin->boot();
$this->logger->info('Plugin booted', [
'name' => $plugin->getName(),
'version' => $plugin->getVersion(),
]);
}
$this->booted = true;
}
public function get(string $name): PluginInterface
{
$this->bootAll();
return $this->plugins[$name]
?? throw new \RuntimeException("Plugin '{$name}' not found");
}
/** @return array<string, PluginInterface> */
public function all(): array
{
$this->bootAll();
return $this->plugins;
}
}<?php
// src/DependencyInjection/Compiler/PluginRegistryPass.php
declare(strict_types=1);
namespace App\DependencyInjection\Compiler;
use App\Plugin\PluginRegistry;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
final class PluginRegistryPass implements CompilerPassInterface
{
private const CORE_VERSION = '7.2.0';
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition(PluginRegistry::class)) {
return;
}
$tagged = $container->findTaggedServiceIds('app.hr_plugin');
$plugins = [];
foreach ($tagged as $serviceId => $tags) {
$tag = $tags[0];
$minCoreVersion = $tag['min_core_version'] ?? '0.0.0';
if (version_compare(self::CORE_VERSION, $minCoreVersion, '<')) {
throw new \LogicException(sprintf(
"Plugin '%s' requires core %s but core is %s",
$serviceId,
$minCoreVersion,
self::CORE_VERSION,
));
}
$plugins[$serviceId] = [
// 'name' (du tag) vaut toujours 'app.hr_plugin' : on lit 'plugin_name'.
'name' => $tag['plugin_name'] ?? $serviceId,
'version' => $tag['version'] ?? '1.0.0',
'requires' => $this->parseList($tag['requires'] ?? ''),
'provides' => $this->parseList($tag['provides'] ?? ''),
];
}
$sorted = $this->topologicalSort($plugins);
$references = array_map(
fn (string $id) => new Reference($id),
$sorted,
);
$container->getDefinition(PluginRegistry::class)
->setArgument('$taggedPlugins', $references);
}
/** @return list<string> */
private function parseList(string $value): array
{
return array_values(array_filter(array_map('trim', explode(',', $value))));
}
/**
* @param array<string, array{name: string, version: string, requires: list<string>, provides: list<string>}> $plugins
* @return list<string>
*/
private function topologicalSort(array $plugins): array
{
$provided = [];
foreach ($plugins as $serviceId => $info) {
foreach ($info['provides'] as $capability) {
$provided[$capability] = $serviceId;
}
$provided[$info['name']] = $serviceId;
}
$sorted = [];
$visited = [];
$visiting = [];
$visit = function (string $serviceId) use (
&$visit, &$sorted, &$visited, &$visiting, $plugins, $provided
): void {
if (isset($visited[$serviceId])) {
return;
}
if (isset($visiting[$serviceId])) {
throw new \LogicException("Plugin cycle detected involving '{$serviceId}'");
}
$visiting[$serviceId] = true;
foreach ($plugins[$serviceId]['requires'] as $required) {
$depService = $provided[$required] ?? throw new \LogicException(
"Plugin '{$serviceId}' requires '{$required}' which is not provided"
);
$visit($depService);
}
unset($visiting[$serviceId]);
$visited[$serviceId] = true;
$sorted[] = $serviceId;
};
foreach (array_keys($plugins) as $serviceId) {
$visit($serviceId);
}
return $sorted;
}
}<?php
// src/Plugin/SyntecPayrollPlugin.php
declare(strict_types=1);
namespace App\Plugin;
use Psr\Log\LoggerInterface;
final class SyntecPayrollPlugin implements PluginInterface
{
public function __construct(private readonly LoggerInterface $logger) {}
public function getName(): string { return 'syntec_payroll'; }
public function getVersion(): string { return '2.1.0'; }
public function boot(): void
{
$this->logger->info('Syntec payroll plugin booted');
}
}# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
_instanceof:
App\Plugin\PluginInterface:
tags:
- { name: 'app.hr_plugin' }
App\Plugin\SyntecCollectiveAgreementPlugin:
tags:
# 'name' ici = nom du TAG. Les autres clés sont les attributs lus par le pass.
- name: 'app.hr_plugin'
plugin_name: 'syntec_collective'
version: '1.5.0'
min_core_version: '7.0.0'
provides: 'syntec_rules'
App\Plugin\SyntecPayrollPlugin:
tags:
- name: 'app.hr_plugin'
plugin_name: 'syntec_payroll'
version: '2.1.0'
min_core_version: '7.0.0'
requires: 'syntec_rules'⚠️ Piège de nomenclature lu en review : dans un tag YAML, la clé
nameest réservée au nom du tag lui-même (app.hr_plugin). Si votre pass a aussi besoin d'un « nom logique » de plugin, n'utilisez pas la clénamepour ça —findTaggedServiceIds()la renvoie certes dans le tableau d'attributs, mais elle vaut alorsapp.hr_plugin(le nom du tag), pas le nom du plugin. C'est un bug subtil et fréquent : lire$tag['name']en croyant récupérer le nom logique récupère en fait le nom du tag. La parade adoptée ici est d'introduire une clé dédiéeplugin_name, que le pass lit explicitement ($tag['plugin_name'] ?? $serviceId, voir plus haut).
<?php
// src/Kernel.php
declare(strict_types=1);
namespace App;
use App\DependencyInjection\Compiler\PluginRegistryPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
final class Kernel extends BaseKernel
{
use MicroKernelTrait;
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(
new PluginRegistryPass(),
PassConfig::TYPE_BEFORE_OPTIMIZATION,
);
}
}Tests : (1) unitaire du pass avec un ContainerBuilder vide qui ajoute 3 plugins fictifs avec dépendances croisées et vérifie l'ordre final, (2) test de détection de cycle qui doit lever une LogicException claire, (3) test d'intégration via KernelTestCase qui boote le kernel réel et vérifie que PluginRegistry::all() retourne les plugins dans le bon ordre, avec boot() appelé une seule fois par plugin grâce au flag $booted.
🔁 Quand utiliser / éviter
À utiliser quand
- Vous écrivez un bundle distribuable qui doit s'intégrer à des services consommateurs (ex. enregistrer des handlers, des listeners, des stratégies).
- Vous avez besoin d'un registry dynamique dont les éléments sont déclarés par d'autres bundles ou par des fichiers de configuration.
- Vous voulez décorer transparenter un service du framework (logger, repository, etc.).
- Vous devez valider/manipuler la configuration entre
Extension::load()et le dump final (ex. injecter un paramètre calculé).
À éviter quand
- Le besoin se résume à injecter une liste de services :
tagged_iteratoroutagged_locatorsuffisent, pas besoin de pass. - Vous voulez du runtime dynamique : un compiler pass ne s'exécute qu'à la compilation (cache:clear). Pour du runtime, il vous faut un service avec une stratégie interne (Factory, Provider).
- Le code est applicatif et non réutilisable : YAML/PHP DSL suffisent largement.
- Vous voulez changer le service en cours d'exécution : impossible, le container est figé. Utiliser un état interne ou un nouveau service.
Arbre de décision : pass ou pas pass ?
| Besoin | Outil recommandé | Pourquoi |
|---|---|---|
| Injecter une liste de services tagués | !tagged_iterator / #[AutowireIterator] | Lazy, zéro code, lisible. |
| Accès par clé à un service tagué | !tagged_locator / #[AutowireLocator] | ServiceLocator lazy, O(1). |
| Tag automatique sur une interface | _instanceof / registerForAutoconfiguration() | Pas de pass, suit l'autodiscovery. |
| Décorer un service | decorates: / #[AsDecorator] | Géré nativement par le moteur. |
| Trier topologiquement / valider des dépendances cross-service | Compiler pass | Logique impérative impossible en config déclarative. |
| Élaguer/activer des services selon un paramètre, un bundle, un tenant | Compiler pass | removeDefinition()/setAlias() conditionnels. |
| Réécrire des arguments après autowiring | Compiler pass en TYPE_OPTIMIZE | Seule phase où les refs sont résolues. |
| Injecter de la donnée runtime (DB, requête HTTP) | Ni l'un ni l'autre → Factory / Provider | Le container est figé à la compilation. |
Production : ce qui casse à l'échelle
- Coût de compilation. Chaque pass tourne à chaque
cache:clear/cache:warmup(déploiement, CI, premier hit après deploy). Un pass en O(n) sur tous les services × dizaines de passes = secondes de warmup. Mesurer avecbin/console cache:warmup -vvvet le profiler. Filtrer toujours par tag. - OPcache & déploiement atomique. Le container compilé est un gros fichier PHP. En déploiement blue/green ou avec
cache.system, unvar/cache/prodpartagé entre deux releases provoque desclass already declared/ mismatch. Règle : un répertoire de cache par release, warmup avant de basculer le symlink,opcache_reset()oucachetoolau switch. - Observabilité. Le seul oracle de vérité est
debug:container --env=prod(le container prod, pas dev — ils diffèrent : pas deDebugClassLoader, services de profiling absents). En incident, comparezdebug:container --env=prodau comportement runtime avant de soupçonner le code métier. - Sécurité. Un pass qui lit
kernel.bundlesou des paramètres pour activer des services est un point de contrôle de surface d'attaque : un service de debug/admin accidentellement laissé en prod est une faille. Préférer des passes qui suppriment explicitement en prod (removeDefinition) plutôt qu'activer par défaut.
🏋️ Exercices
Progression : implémenter → durcir façon production → casser puis réparer. Chaque exercice se valide par un test PHPUnit ou un
debug:container.
Exercice 1 — Tag + iterator, sans pass (échauffement)
Objectif : enregistrer 3 NotificationHandlerInterface (email, sms, slack) et les injecter dans le NotificationDispatcher sans écrire un seul compiler pass, avec priorités.
Indice/Solution : _instanceof pour auto-tagger l'interface, #[AsTaggedItem(priority: N)] (ou attribut de tag) sur chaque handler, et !tagged_iterator { tag: ..., default_priority_method: 'getPriority' } comme argument. Vérifier l'ordre via un test d'intégration KernelTestCase.
Exercice 2 — Votre premier pass : ServiceLocator par clé
Objectif : écrire PaymentStrategyPass qui collecte les services tagués app.payment_strategy (attribut key), construit un ServiceLocator indexé par key, et l'injecte dans un PaymentEngine. Lever une LogicException claire si deux stratégies partagent la même key.
Indice/Solution : findTaggedServiceIds() → boucle → détecter la collision (isset($services[$key])) → ServiceLocatorTagPass::register($container, $services) → setArgument('$locator', $locator). Test unitaire avec un ContainerBuilder nu.
Exercice 3 — Décoration conditionnelle en chaîne (production-grade)
Objectif : décorer tous les services tagués app.payment_strategy par un AuditingStrategyDecorator (capture inputs/outputs/durée), via un pass qui crée dynamiquement une Definition de décorateur par stratégie. Le décorateur ne doit s'activer que si le paramètre app.audit_enabled est true.
Indice/Solution : pour chaque service tagué, $container->register($id.'.audited', AuditingStrategyDecorator::class)->setDecoratedService($id)->setArguments([new Reference($id.'.audited.inner'), ...]). Court-circuiter le pass entier si !$container->getParameter('app.audit_enabled'). Vérifier avec debug:container --tag=app.payment_strategy que les décorateurs apparaissent.
Exercice 4 — Tri topologique + détection de cycle (break-then-fix)
Objectif : reprendre PluginRegistryPass. (a) Écrire un test qui enregistre A→requires B, B→requires C, C→requires A et prouver qu'une LogicException "cycle detected" est levée. (b) Casser le tri (retirer le $visiting guard) et constater le dépassement de pile / l'ordre faux. (c) Réparer et ajouter un test sur l'ordre stable d'un DAG non trivial (A,B indépendants ; C requires A ; D requires C,B).
Indice/Solution : le guard $visiting distingue « en cours de visite » (→ cycle) de « visité » (→ skip). Sans lui, un cycle boucle indéfiniment. Test d'ordre : asserter que l'index de A < index de C < index de D et de B < index de D.
Exercice 5 — Phase trip : lire un argument résolu (avancé)
Objectif : écrire un pass qui, après autowiring, vérifie que tout service implémentant EncryptorInterface reçoit bien un argument $key non vide, et lève une erreur de compilation sinon. Faire échouer le même pass placé en TYPE_BEFORE_OPTIMIZATION pour comprendre pourquoi.
Indice/Solution : en BEFORE_OPTIMIZATION, l'argument $key peut être encore null/non résolu (autowiring pas passé) → faux positifs. En TYPE_OPTIMIZE (avec une priorité basse pour passer après les passes internes), $definition->getArgument('$key') reflète la valeur finale. Démontrer la différence par deux exécutions.
Exercice 6 — Bundle distribuable complet (capstone)
Objectif : packager les exercices 2–3 en un vrai bundle (Bundle, Extension, Configuration, pass enregistré dans Bundle::build()), avec une option audit.enabled validée par Configuration, et publier un attribut PHP #[AsPaymentStrategy(key: '...')] autoconfiguré.
Indice/Solution : Bundle::build(ContainerBuilder $container) appelle $container->addCompilerPass(...). L'attribut maison se branche via $container->registerAttributeForAutoconfiguration(AsPaymentStrategy::class, function (ChildDefinition $def, AsPaymentStrategy $attr) { $def->addTag('app.payment_strategy', ['key' => $attr->key]); }) (Symfony 6.3+). Test : installer le bundle dans une app de test et vérifier debug:container --tag=app.payment_strategy.
🎤 En entretien
Q : Pourquoi un compiler pass plutôt qu'une simple Factory au runtime ? R : Le pass s'exécute une fois à la compilation ; il fige le wiring dans le container PHP, donc zéro coût au runtime et tout est visible via debug:container. Une Factory recalcule à chaque requête et masque la résolution. On choisit le pass pour de l'extensibilité statique (plugins, tags), la Factory pour de la sélection dépendante de la donnée runtime.
Q : Quelle phase pour quel besoin, et pourquoi BEFORE_OPTIMIZATION par défaut ? R : BEFORE_OPTIMIZATION car les Definition y sont brutes et librement manipulables avant que l'autowiring/les alias ne soient résolus — c'est là qu'on wire des tags. On passe en TYPE_OPTIMIZE seulement pour lire des arguments résolus, et en AFTER_REMOVING pour valider/nettoyer après élagage. Se tromper de phase = lire des définitions pas encore résolues (faux positifs) ou agir sur des services déjà supprimés.
Q : Pourquoi ne jamais faire new MonService() dans un pass ? R : Parce qu'on perd l'autowiring, la lazy-loading et l'inlining ; le service court-circuite le container, n'apparaît pas dans debug:container, et casse le profiling. On manipule toujours des Definition + Reference ; l'instanciation réelle est déléguée au container compilé, paresseusement.
Q : tagged_iterator vs ServiceLocator : comment choisir ? R : tagged_iterator quand on itère sur tous les services (ex. chercher le premier qui supports()), ServiceLocator quand on accède par clé à un seul (ex. stratégie choisie par une valeur DB). Les deux sont lazy, mais l'iterator instancie tout ce qu'on traverse tandis que le locator n'instancie que la clé demandée — décisif quand il y a des dizaines de candidats coûteux.
🔗 Liens
- Symfony Docs — Compiler Passes
- Symfony Docs — Service Decoration
- Symfony Docs — Tags
- Symfony Docs — Lazy Services
- SymfonyCasts — Compiler Passes deep dive
- GitHub —
RegisterListenersPass(référence)
Récap final
Les compiler passes sont l'épine dorsale de l'extensibilité Symfony. En production, le container est un fichier PHP monolithique compilé une fois, où l'autowiring, les tags et les décorations sont figés. La compilation se déroule en cinq phases ordonnées (BeforeOptimization → Optimization → BeforeRemoving → Removing → AfterRemoving), chacune dédiée à une famille de transformations — vos passes maison vivent presque toujours en BeforeOptimization. La règle d'or : ne jamais instancier dans un pass, toujours manipuler des Definition et des Reference ; filtrer via findTaggedServiceIds() ; respecter les priorités et nettoyer les tags consommés. Pour les besoins simples (collecter une liste de handlers), préférer tagged_iterator ou les attributs #[AutowireIterator]/#[AutowireLocator] introduits en Symfony 6.1+ — c'est plus court, plus lisible, et tout aussi performant. Réserver les compiler passes aux cas où vous manipulez réellement les définitions (auto-tagging conditionnel, décoration dynamique, plugin discovery, registry pattern). Et toujours valider le résultat via bin/console debug:container --env=prod — c'est le seul moyen d'avoir la vérité absolue sur ce qui tourne en production.