Skip to content

Dependency Injection — service container

TL;DR — Le container Symfony est un graphe de services compilé en PHP statique au cache warmup. Autowiring + autoconfigure + _instanceof te donnent 95% des cas sans config. Tags + compiler passes + decorators + factories couvrent les 5% restants. Comprendre la compilation (vs runtime) est ce qui sépare le débutant du senior.

🧠 Mental model — ASCII diagram + analogie

            BUILD TIME (cache:warmup)                  RUNTIME
   ┌───────────────────────────────────┐         ┌────────────────────┐
   │  services.yaml / config attrs     │         │  $container        │
   │           │                       │         │  ::get('app.foo')  │
   │           ▼                       │         │       │            │
   │  ContainerBuilder  ◄── extensions │         │       ▼            │
   │           │                       │         │  new Foo(          │
   │           ▼                       │         │     new Bar(...)   │
   │  CompilerPasses  (×N)             │         │  )                 │
   │  - ResolveDefinitions             │         │                    │
   │  - AutowirePass                   │         │  zero reflection   │
   │  - AutoconfigurePass              │         │  zero parsing      │
   │  - RemoveUnusedDefinitions        │         │                    │
   │           │                       │         │                    │
   │           ▼                       │         │                    │
   │  Dumped PHP container class       ├────────►│  require once      │
   │  var/cache/dev/App_KernelDev...   │         │                    │
   └───────────────────────────────────┘         └────────────────────┘

Analogie : le container est un plan d'usine IKEA. À la compilation, Symfony lit toutes tes instructions (services.yaml, attributes, extensions) et imprime un manuel d'assemblage en PHP pur. À runtime, plus de réflexion : il suit le manuel ligne par ligne pour fabriquer un service. C'est pour ça que la prod est rapide même avec 2000 services.

Les 4 lois que tu dois internaliser

  1. Compile-time ≠ runtime. Tout ce qui est "intelligent" (autowiring, résolution des tags, détection de cycles, suppression du mort) arrive une seule fois, au warmup. Le container dumpé est du PHP bête et méchant. Corollaire : un bug d'autowiring ne se manifeste jamais au runtime — il explose au cache:clear. Si ça compile, ça câble correct.
  2. Le container est un détail d'implémentation, pas une API. Le but de la DI est que ton code ne connaisse jamais le container. Injecter ContainerInterface est une odeur (service locator déguisé). La seule exception légitime : un service locator typé et scopé (#[AutowireLocator]).
  3. Un service est sans état partagé et instancié une fois (singleton de scope container). Si tu stockes une requête, un user courant ou un tenant dans une propriété mutable d'un service, tu as un bug en messenger worker/runtime swoole/roadrunner où le process vit plusieurs requêtes. Reste stateless ou injecte un RequestStack.
  4. Tout ce qui peut être résolu à la compilation doit l'être. Une match sur un %env% dans un compiler pass est plus rapide et plus sûre qu'un if runtime répété à chaque requête. Le warmup paie une fois ; le runtime paie à chaque hit.

Comment un staff raisonne devant un besoin de câblage

Besoin                                   → Outil DI (du moins au plus lourd)
─────────────────────────────────────────────────────────────────────────
1 dépendance, 1 implémentation           → autowiring nu (rien à écrire)
1 interface, 1 impl, nom ambigu          → alias ou #[Target]
1 interface, N impl, choix par clé       → #[AutowireLocator] (lazy, map)
1 interface, N impl, on les parcourt tous→ #[AutowireIterator] + priority
N impl découvertes dynamiquement         → _instanceof (config) ou compiler pass
ajouter un comportement transverse       → decorator (#[AsDecorator])
construction conditionnelle/dynamique    → factory
SDK tiers non-injectable                 → factory + bind des scalaires
service lourd rarement appelé            → #[Lazy] (après profilage)

Règle d'or : descends cette liste, ne remonte pas. Un compiler pass est puissant mais coûte en lisibilité et en testabilité — n'en écris un que quand _instanceof ne suffit pas (priority calculée, filtrage par env, réécriture de définitions).

🛠️ Code minimal — autowiring + decorator + tag

yaml
# config/services.yaml
services:
    _defaults:
        autowire: true       # injecte par type-hint
        autoconfigure: true  # tag automatique selon interface
        public: false        # interdire $container->get() direct
        bind:
            string $stripeApiKey: '%env(STRIPE_SECRET)%'
            iterable $paymentGateways: !tagged_iterator app.payment_gateway

    App\:
        resource: '../src/'
        exclude:
            - '../src/{DependencyInjection,Entity,Kernel.php,Tests}'

    # Tag explicite si besoin de priority
    App\Payment\StripeGateway:
        tags:
            - { name: app.payment_gateway, priority: 100 }

    # Decorator : enrichir un service existant
    App\Logger\TimingLoggerDecorator:
        decorates: 'monolog.logger'
        arguments: ['@.inner', '@stopwatch']
php
// src/Payment/GatewayResolver.php
<?php
namespace App\Payment;

use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

final readonly class GatewayResolver
{
    public function __construct(
        #[AutowireIterator('app.payment_gateway', defaultPriorityMethod: 'getPriority')]
        private iterable $gateways,
    ) {}

    public function pick(string $method): PaymentGatewayInterface
    {
        foreach ($this->gateways as $gateway) {
            if ($gateway->supports($method)) {
                return $gateway;
            }
        }
        throw new \LogicException("No gateway for {$method}");
    }
}
php
// src/DependencyInjection/Compiler/AutoTagPass.php
<?php
namespace App\DependencyInjection\Compiler;

use App\Payment\PaymentGatewayInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

final class AutoTagPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        foreach ($container->getDefinitions() as $id => $definition) {
            $class = $definition->getClass() ?? $id;
            if (!class_exists($class) || !is_subclass_of($class, PaymentGatewayInterface::class)) {
                continue;
            }
            $definition->addTag('app.payment_gateway');
        }
    }
}

🎯 Patterns courants

  1. _instanceof config — au lieu de tagger manuellement, déclare une fois : _instanceof: App\Handler\Handler: tags: [app.handler]. Toutes les classes implémentant l'interface sont taggées automatiquement.
  2. Decorator patterndecorates: 'app.foo' + injection de @.inner : ajouter logging, caching, retry sans toucher au code original. Stackable (decorate un decorator, decoration_priority ordonne la pile). Idiome moderne (6.1+) : l'attribut #[AsDecorator('app.foo')] sur la classe + #[AutowireDecorated] sur l'argument inner — plus de YAML :
php
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

#[AsDecorator(decorates: 'app.foo', priority: 10)]
final class CachingFoo implements FooInterface
{
    public function __construct(
        #[AutowireDecorated] private FooInterface $inner,
        private CacheInterface $cache,
    ) {}
}
  1. Factory — pour services dont la construction dépend de config dynamique : factory: ['App\Factory\ClientFactory', 'create']. Utile pour SDK tiers qui ne suivent pas le constructeur injectable.
  2. Service locator vs iterator — un ServiceLocator est paresseux (instancie à la demande, indexé par clé), un iterator instancie tout dès l'itération. Locator = strategy pattern par clé ; iterator = chain of responsibility.
  3. Lazy serviceslazy: true génère un proxy ProxyManager qui n'instancie le service réel qu'au premier appel de méthode. Utile pour service lourd injecté partout mais rarement appelé.
  4. Parameter binding global_defaults.bind: { string $appUrl: '%env(APP_URL)%' } injecte automatiquement $appUrl dans tous les constructeurs. Plus DRY que des args explicites.

🔄 Versions — Symfony 5.4 / 6.4 / 7.x

  • 5.4 : #[Required] attribute, _instanceof mature, autoconfigure par interface très large. Service locator via Symfony\Contracts\Service\ServiceProviderInterface. Annotations toujours supportées via doctrine/annotations.
  • 6.0 : suppression Symfony\Component\DependencyInjection\Annotation\* (legacy), uniquement attributes. #[AsTaggedItem] arrive (6.2) pour spécifier priority/index directement sur la classe.
  • 6.3 : #[Autowire] attribute généralisé : #[Autowire('%kernel.project_dir%/var')] string $varDir. Permet d'éviter les bind: YAML.
  • 6.4 LTS : #[AutowireLocator], #[AutowireIterator], #[AutowireCallable], #[AutowireServiceClosure] — toute la suite "autowire by attribute" est stable. Bundle config peut être typée.
  • 7.0 : suppression Container::set() à runtime pour services privés, suppression de plusieurs APIs legacy Reference::IGNORE_*.
  • 7.1+ : #[Lazy] attribute (au lieu de lazy: true YAML), #[Exclude] attribute pour exclure de PSR-4 resource scan. #[WhenNot] complète #[When] pour env conditionnel.

⚠️ Pitfalls

  1. Service public vs privé — par défaut depuis 4.0 tout est privé. $container->get('app.foo') à runtime throw. Pour tests : static::getContainer() retourne un container test où les privés sont accessibles.
  2. Autowiring ambiguous — 2 services implémentent LoggerInterface → erreur compile. Soluce : alias explicite Psr\Log\LoggerInterface $myLogger:: '@monolog.logger.app' ou #[Target('app')].
  3. Circular references — service A inject B inject A. La compilation throw. Soluce : redesign, ou lazy: true sur un des deux pour casser le cycle au runtime.
  4. Compiler pass au mauvais momentaddCompilerPass() doit être appelé dans build() du Kernel, pas dans boot(). Passer le priority et type (BEFORE/AFTER OPTIMIZATION) si ordre critique.
  5. Tag sans priority et iterator non-déterministe — l'ordre d'itération dépend de l'ordre de déclaration des services. Toujours définir priority si l'ordre compte.
  6. autoconfigure: true cassé par interface multiple — une classe qui implémente 3 interfaces taggées hérite des 3 tags. Parfois inattendu.
  7. %env()% à mauvais endroit%env(FOO)% dans un argument YAML : OK. Dans une key YAML : non. Dans une valeur de tag : OK mais résolu à runtime, donc pas dispo en compile-time logic.
  8. Decorator + @.inner autowiring — si la classe decorator type-hint l'interface decorée, l'autowiring re-pointe vers le decorator → boucle infinie. Toujours injecter @.inner explicitement.

🧪 Testing

php
// tests/Service/GatewayResolverTest.php
<?php
namespace App\Tests\Service;

use App\Payment\GatewayResolver;
use App\Payment\StripeGateway;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class GatewayResolverTest extends KernelTestCase
{
    public function testStripeIsResolved(): void
    {
        self::bootKernel();
        $resolver = static::getContainer()->get(GatewayResolver::class);

        self::assertInstanceOf(StripeGateway::class, $resolver->pick('card'));
    }

    public function testServiceIsRegistered(): void
    {
        self::bootKernel();
        self::assertTrue(static::getContainer()->has(GatewayResolver::class));
    }
}

Tester un compiler pass isolément :

php
$container = new ContainerBuilder();
$container->register(StripeGateway::class)->setAutoconfigured(true);
(new AutoTagPass())->process($container);
self::assertTrue($container->getDefinition(StripeGateway::class)->hasTag('app.payment_gateway'));

Debug à la CLI :

bash
php bin/console debug:container --tag=app.payment_gateway
php bin/console debug:container App\\Payment\\GatewayResolver --show-arguments
php bin/console debug:autowiring LoggerInterface

🎬 Cas d'usage concrets

Scénario 1 — SaaS LegalTech (Doctrine cabinet/notaire) : modules optionnels par bundle interne

Contexte : un éditeur LegalTech français vend une suite à 1 200 cabinets d'avocats et études notariales. Selon le pack souscrit, le client a accès à des modules distincts : Contracts, Billing, Notariat, eDiscovery. Chaque module est implémenté comme bundle interne (AcmeContractsBundle, etc.) versionné indépendamment, chacun avec son extension DI.

L'astuce : la configuration centrale packs.yaml du tenant active dynamiquement les bundles dans Kernel::registerBundles() via lecture du Tenant::getPack(). Les services internes du module utilisent l'autoconfigure de leur propre bundle, avec un tag legal.contract_template_provider que le bundle Contracts agrège via _instanceof. Quand un cabinet ne souscrit pas à eDiscovery, le bundle n'est tout simplement jamais enregistré : zéro service compilé, zéro footprint mémoire.

Bénéfice : 6 modules cohabitent dans le même monorepo sans qu'aucun service inutile ne soit booté en prod. Le temps de warmup d'un tenant minimal est tombé de 2,8 s à 900 ms.

Scénario 2 — E-commerce omnicanal (Decathlon-like) : agrégation providers via compiler pass

Contexte : marketplace ecommerce avec 80 marketplaces partenaires (Amazon, Cdiscount, ManoMano, Fnac, etc.). Chaque marketplace = un MarketplaceProviderInterface avec pushCatalog, pullOrders, updateStock. Il y a ~80 implémentations dans src/Marketplace/Provider/.

Au lieu de tagger chaque service à la main, un MarketplaceProvidersPass (compiler pass) scanne le container, détecte tous les services implémentant l'interface, leur attribue le tag app.marketplace_provider avec une priority lue depuis l'attribut #[MarketplacePriority] posé sur la classe. Un MarketplaceRouter reçoit un #[AutowireIterator('app.marketplace_provider', defaultPriorityMethod: null)] et dispatch les ordres par code marketplace.

Effet net : ajouter un nouveau provider = une seule classe à créer (pas de YAML, pas de tag manuel), 0 modification du routeur. L'équipe a onboardé 22 nouveaux providers en un quarter sans toucher la config DI.

Scénario 3 — FinTech (Qonto-like) : décoration de MailerInterface pour conformité

Contexte : néobanque pro qui envoie ~400 k emails/mois (factures, IBAN, KYC, support). La compliance LCB-FT impose : (1) chiffrement des PDFs joints avec password client, (2) horodatage cryptographique du contenu, (3) log de tout email envoyé dans un audit trail. Modifier chaque appel à $mailer->send() dans 60 controllers/handlers = ingérable.

Solution : décorer le service mailer.mailer avec un CompliantMailerDecorator implements MailerInterface via decorates: 'mailer.mailer' et injection de @.inner. Le decorator intercepte chaque send(Email $email, ?Envelope $envelope), applique le chiffrement sur les DataPart PDF, ajoute un header X-Audit-Id, enregistre l'envoi dans ComplianceAuditEntity via Doctrine. Aucun consommateur n'a connaissance du décorateur — l'autowiring de MailerInterface pointe naturellement vers le decorator.

Bénéfice : un audit compliance ACPR passé sans remarque sur la traçabilité des envois, et une couverture 100% sans risque de "controller oublié".

🛠️ Exemple end-to-end

Use case : marketplace BtoB industrielle — système de pricing multi-strategies (prix catalogue, prix négocié, prix volume, prix promo). Selon le panier, plusieurs stratégies peuvent s'appliquer en cascade. On veut pouvoir activer/désactiver une stratégie via env var sans deploy, et auto-découvrir les nouvelles classes via interface.

php
// src/Pricing/PricingStrategyInterface.php
<?php
declare(strict_types=1);

namespace App\Pricing;

use App\Pricing\Dto\PricingContext;
use App\Pricing\Dto\PriceAdjustment;

interface PricingStrategyInterface
{
    public function supports(PricingContext $context): bool;

    public function apply(PricingContext $context): PriceAdjustment;

    public static function priority(): int;
}
php
// src/Pricing/Dto/PricingContext.php
<?php
declare(strict_types=1);

namespace App\Pricing\Dto;

use App\Catalog\Entity\Product;
use App\Customer\Entity\Customer;
use Money\Money;

final readonly class PricingContext
{
    public function __construct(
        public Product $product,
        public Customer $customer,
        public int $quantity,
        public Money $unitListPrice,
        public ?string $promoCode = null,
    ) {}
}
php
// src/Pricing/Strategy/VolumeDiscountStrategy.php
<?php
declare(strict_types=1);

namespace App\Pricing\Strategy;

use App\Pricing\Dto\PriceAdjustment;
use App\Pricing\Dto\PricingContext;
use App\Pricing\PricingStrategyInterface;

final readonly class VolumeDiscountStrategy implements PricingStrategyInterface
{
    public function supports(PricingContext $context): bool
    {
        return $context->quantity >= 100;
    }

    public function apply(PricingContext $context): PriceAdjustment
    {
        $percent = match (true) {
            $context->quantity >= 1000 => 15,
            $context->quantity >= 500  => 10,
            default                    => 5,
        };

        return new PriceAdjustment(
            label: "Volume -{$percent}%",
            percentOff: $percent,
        );
    }

    public static function priority(): int
    {
        return 200; // s'applique avant les promos
    }
}
php
// src/DependencyInjection/Compiler/PricingStrategiesPass.php
<?php
declare(strict_types=1);

namespace App\DependencyInjection\Compiler;

use App\Pricing\PricingStrategyInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

final class PricingStrategiesPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        $disabled = $container->getParameter('app.pricing.disabled_strategies');
        \assert(\is_array($disabled));

        foreach ($container->getDefinitions() as $id => $definition) {
            $class = $definition->getClass() ?? $id;
            if (!class_exists($class) || !is_subclass_of($class, PricingStrategyInterface::class)) {
                continue;
            }
            if (\in_array($class, $disabled, true)) {
                $container->removeDefinition($id);
                continue;
            }
            $definition->addTag('app.pricing_strategy', ['priority' => $class::priority()]);
        }
    }
}
yaml
# config/services.yaml
parameters:
    app.pricing.disabled_strategies: '%env(csv:PRICING_DISABLED_STRATEGIES)%'

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\Pricing\Strategy\:
        resource: '../src/Pricing/Strategy/'
php
// src/Pricing/PricingEngine.php
<?php
declare(strict_types=1);

namespace App\Pricing;

use App\Pricing\Dto\PricingContext;
use App\Pricing\Dto\PricedQuote;
use Money\Money;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

final readonly class PricingEngine
{
    public function __construct(
        #[AutowireIterator('app.pricing_strategy')]
        private iterable $strategies,
    ) {}

    public function quote(PricingContext $context): PricedQuote
    {
        $adjustments = [];
        foreach ($this->strategies as $strategy) {
            if ($strategy->supports($context)) {
                $adjustments[] = $strategy->apply($context);
            }
        }
        $finalUnit = $context->unitListPrice;
        foreach ($adjustments as $adj) {
            $finalUnit = $finalUnit->subtract(
                $context->unitListPrice->multiply((string) ($adj->percentOff / 100))
            );
        }

        return new PricedQuote(
            unit: $finalUnit,
            total: $finalUnit->multiply((string) $context->quantity),
            adjustments: $adjustments,
        );
    }
}

Le pricing devient : ajouter une nouvelle stratégie = créer une classe dans Strategy/. La désactiver en prod (rollback rapide d'une promo défaillante) = setter PRICING_DISABLED_STRATEGIES=App\Pricing\Strategy\PromoCodeStrategy et cache:clear. Zéro modification du moteur, zéro YAML touché.


🔁 Quand utiliser / éviter

  • Tag + iterator : quand tu veux du chain of responsibility (form types, voters, event listeners). Évite si une seule implémentation est jamais possible.
  • Decorator : ajouter une cross-cutting concern sans modifier le code. Évite si tu peux juste injecter une dépendance ; decorator = quand tu veux remplacer l'objet partout sans changer ses consommateurs.
  • Factory : library tierce avec API non-injectable. Évite pour ton propre code → fais un constructeur DI-friendly.
  • Lazy : profile d'abord (debug:container --types). Si le service ne fait rien en construction (constructor pure), pas la peine.
  • Service locator : strategy pattern avec lookup dynamique par clé. Évite si l'iterator suffit (moins de boilerplate).

Tableau de décision — iterator vs locator vs decorator vs factory

Critère#[AutowireIterator]#[AutowireLocator]DecoratorFactory
Instanciationtoutes dès l'itérationparesseuse, à la demandel'inner est instancié (sauf si lazy)une fois, au build du décoré
Accèsséquentiel (foreach)par clé (get($id))transparent (remplace)transparent
Pattern viséchain of responsibilitystrategy / dispatchcross-cutting concernconstruction conditionnelle
Coût mémoireN services bootés1 service booté+1 wrappernul
Cas typiquevoters, listeners, pipelinehandlers par type de messagelogging/cache/retry/auditclient SDK tiers
Anti-usagebesoin d'un seul élémenttu parcours tout de toute façontu pouvais juste injecterton propre code injectable

Le piège mémoire : un iterator sur 80 providers qui ont chacun une connexion HTTP/DB en constructeur boote 80 connexions à chaque requête, même si tu n'en utilises qu'une. Dans ce cas → locator (lazy) + une clé de routage. C'est la première chose qu'un staff vérifie quand le warmup ou le first-byte explose.

🏭 Concerns de production

Performance

  • Le coût réel n'est pas le lookup, c'est la construction. Un graphe profond (service A → B → C → D, chacun avec des deps) instancie toute la chaîne au premier get. Profile avec le Symfony Profiler → onglet "Performance" ou blackfire run. Si un controller tire 200 services, suspecte un iterator ou un service god-object injecté partout.
  • #[Lazy] casse la chaîne : le proxy n'instancie le vrai service qu'au premier appel de méthode. Mesure avant/après — un proxy ProxyManager a un coût d'allocation non nul ; il ne vaut le coup que si la construction est chère (I/O, parsing) ET conditionnelle.
  • Containers > ~1500 services : Symfony splitte le dump en plusieurs fichiers et active l'inlineFactories. Garde container.dumper.inline_factories: true en prod. OPcache doit être chaud — un container froid sans OPcache, c'est des ms de require par requête.

Observabilité

  • debug:container --tag=app.payment_gateway te donne l'ordre réel post-priority — c'est la source de vérité, pas ta lecture du YAML.
  • debug:container --deprecations liste les services dépréciés tirés transitivement (audit avant un upgrade majeur).
  • En prod, un service qui throw dans son constructeur fait échouer la première requête qui le tire, pas le warmup (sauf si référencé en compile-time). Loggue dans les factories, jamais de logique métier dans un constructeur.

Sécurité

  • public: false par défaut n'est pas cosmétique : un service public est joignable via $container->get() et donc potentiellement via du code attaquant qui a un handle sur le container (ex. désérialisation). Garde tes services privés.
  • Secrets : injecte via %env(SECRET)% / secrets:set, jamais en dur dans services.yaml (qui finit en clair dans le container dumpé sous var/cache). Les %env()% restent résolus paresseusement au runtime — ils n'apparaissent pas en clair dans le dump.
  • #[Autowire(env: 'DB_DSN')] est lisible mais expose le nom de la var dans le code ; pour les secrets sensibles, préfère un binding centralisé documenté.

🪜 Approfondissement — anatomie du container compilé

Quand tu fais php bin/console cache:warmup --env=prod, Symfony génère un fichier comme var/cache/prod/App_KernelProdContainer.php (souvent splitté en plusieurs .php pour gros containers). Extrait simplifié :

php
final class App_KernelProdContainer extends Container
{
    protected function getApp_Payment_StripeGatewayService(): \App\Payment\StripeGateway
    {
        return $this->privates['App\Payment\StripeGateway'] ??= new \App\Payment\StripeGateway(
            $this->getEnv('STRIPE_API_KEY'),
            new \GuzzleHttp\Client(['timeout' => 30]),
        );
    }
    // ... 2000 méthodes similaires
}

Observations clés :

  • Chaque service = une méthode privée. Lookup via getService('id') ou direct $this->getXxxService().
  • Premier appel construit + mémoïse dans $this->privates. Appels suivants = lookup tableau.
  • Aucune réflexion, aucun parsing YAML, aucun appel à AnnotationReader.
  • Env vars résolues paresseusement via $this->getEnv(...).

C'est pour cela qu'en prod, instancier 10 services prend microsecondes. La compilation est lente (warmup), le runtime est gratuit.

🪜 Lifecycle des compiler passes

Les compiler passes s'exécutent en 5 phases :

  1. BEFORE_OPTIMIZATION (default) — la majorité des passes user. Container brut, toutes les definitions présentes.
  2. OPTIMIZEResolveDefinitionTemplatesPass, AutowirePass, AutoconfigurePass, ResolveBindingsPass. Le container devient "concret".
  3. BEFORE_REMOVING — derniers ajustements. ResolveTaggedIteratorArgumentPass ici.
  4. REMOVE — suppression des services privés non-référencés (RemoveUnusedDefinitionsPass).
  5. AFTER_REMOVING — finalization. CheckCircularReferencesPass.

Pour ajouter une pass à une phase spécifique :

php
// src/Kernel.php
protected function build(ContainerBuilder $container): void
{
    $container->addCompilerPass(new AutoTagPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 100);
}

🪜 Autowiring inattendus — cas typiques

  • LoggerInterface → autowiré vers monolog.logger ou un channel spécifique via Psr\Log\LoggerInterface $authLogger (channel auth).
  • EventDispatcherInterface → l'event_dispatcher principal. Pour event dispatcher de bus messenger, type-hint explicite.
  • RequestStack → toujours préférable à injecter Request directement (Request ne fait pas sens en CLI, en sub-request, etc.).
  • #[Target('app.foo')] → résout l'ambiguïté quand 2 services implémentent la même interface : #[Target('app.foo')] LoggerInterface $logger.

🔗 Liens

🏋️ Exercices

Progression : implémenter → production-grade → casser puis réparer. Fais-les dans une vraie app Symfony 7.x, vérifie chaque étape avec debug:container.

1. Pipeline de validation auto-découvert (implémenter)

Objectif : un RequestValidator qui parcourt N ValidationRuleInterface taggées automatiquement, ordonnées par priorité, et s'arrête à la première violation.

Indice/Solution : _instanceof: App\Validation\ValidationRuleInterface: tags: [{ name: app.validation_rule }], priorité via #[AsTaggedItem(priority: 30)] sur chaque règle, injection via #[AutowireIterator('app.validation_rule')]. Vérifie l'ordre réel avec debug:container --tag=app.validation_rule.

2. Strategy par clé en service locator (implémenter)

Objectif : un NotificationDispatcher qui choisit un ChannelInterface (email, sms, push, slack) par clé runtime sans booter les 4 canaux à chaque requête.

Indice/Solution : #[AutowireLocator] avec une map ['email' => EmailChannel::class, ...], ou #[AsTaggedItem('email')] + #[AutowireLocator('app.channel', indexAttribute: 'key')]. Prouve la paresse : ajoute un error_log() dans le constructeur d'un canal et vérifie qu'il ne s'imprime qu'au get('email').

3. Decorator stackable d'audit + cache (production-grade)

Objectif : décorer un ReportRepositoryInterface avec deux décorateurs empilés : un cache (court-circuite l'inner) et un audit (loggue chaque appel), dans le bon ordre (cache au-dessus de l'audit).

Indice/Solution : deux classes #[AsDecorator(decorates: ..., priority: N)] ; priority plus haute = plus proche du consommateur. Le cache doit avoir la priorité la plus haute pour court-circuiter avant l'audit. Vérifie la pile : debug:container ReportRepositoryInterface montre la chaîne .inner.

4. Compiler pass qui filtre par env (production-grade)

Objectif : un pass qui supprime du container toute stratégie listée dans %env(csv:FEATURE_OFF)%, et échoue le warmup (exception claire) si une classe listée n'existe pas — pas de typo silencieuse en prod.

Indice/Solution : itère getDefinitions(), removeDefinition() les désactivées, et throw new InvalidArgumentException si un id du CSV n'a matché aucune définition. Teste le pass isolément avec un ContainerBuilder nu (cf. section Testing).

5. Casser puis réparer — référence circulaire (break-then-fix)

Objectif : crée volontairement A → B → A, observe l'échec exact au cache:clear, puis répare de trois façons distinctes et compare-les.

Indice/Solution : l'erreur est Circular reference detected for service "A". Réparations : (a) extraire l'interface partagée dans un 3e service stateless ; (b) #[Lazy] sur une des deux deps (casse le cycle au runtime via proxy, mais cache le problème de design) ; (c) passer par un EventDispatcher / message pour inverser la dépendance. Discute pourquoi (a) est le seul "vrai" fix.

6. Casser puis réparer — state partagé en worker (break-then-fix)

Objectif : un service CurrentTenantHolder qui stocke le tenant dans une propriété mutable ; reproduis la fuite d'état entre deux messages traités par le même worker Messenger long-running.

Indice/Solution : lance messenger:consume avec deux messages de tenants différents et un service qui set/get le tenant — le 2e message voit le tenant du 1er. Fix : rendre le service stateless et passer le tenant dans le stamp/contexte du message, ou utiliser un ResetInterface (kernel.reset tag) qui ré-initialise l'état entre chaque message.

🎤 En entretien

Q : Pourquoi le container Symfony est-il compilé, et que se passe-t-il concrètement au warmup ? R : Pour payer le coût de l'autowiring/réflexion/résolution des tags une seule fois. Le warmup produit une classe PHP (*Container.php) où chaque service est une méthode qui fait new ... en dur, mémoïsée dans $this->privates. Runtime = zéro réflexion, zéro parsing YAML, juste du PHP + OPcache. C'est pourquoi un bug de câblage explose au cache:clear, jamais en prod.

Q : #[AutowireIterator] vs #[AutowireLocator] — quand l'un, quand l'autre ? R : Iterator = on parcourt toutes les implémentations (chain of responsibility, voters), donc toutes sont instanciées dès l'itération. Locator = on en choisit une par clé au runtime, instanciation paresseuse. Si tes implémentations ont des deps coûteuses (HTTP, DB) et que tu n'en utilises qu'une par requête → locator, sinon tu paies N constructions pour rien.

Q : Un junior injecte ContainerInterface pour "récupérer un service quand il en a besoin". Que lui dis-tu ? R : C'est un service locator déguisé qui casse l'inversion de dépendance : les vraies deps deviennent invisibles, le service est intestable (il faut mocker tout le container), et l'autowiring ne peut plus rien optimiser. La bonne réponse selon le cas : injecter les deps directement, ou un #[AutowireLocator] typé et scopé si on a vraiment besoin de lazy-resolution par clé.

Q : Comment éviter qu'un service garde un état entre deux requêtes dans un worker long-running (Messenger, RoadRunner, Swoole) ? R : Garder les services stateless par principe (le scope container = singleton, le process vit plusieurs requêtes). Si un état est inévitable, implémenter ResetInterface + tag kernel.reset pour que Symfony ré-initialise le service entre chaque message/requête, ou passer la donnée volatile (tenant, user) dans le contexte/stamp plutôt que dans une propriété.

Bibliothèque tech perso — Achref