← Tous les cours / Injection de dépendances avancée Cours
00 — LE CONTENEUR

Le conteneur et sa compilation

Le conteneur Symfony n'est pas magique — c'est un compilateur qui transforme un graphe de définitions en PHP pur.

Quand tu lances php bin/console cache:warmup, Symfony exécute un compilateur. Son entrée : un graphe d'objets Definition construits à partir de tes fichiers YAML et attributs PHP. Sa sortie : une classe PHP dans var/cache/ qui instancie tes services sans aucune réflexion.

ContainerBuilder
Classe utilisée uniquement à la compilation. Elle parse la configuration, crée les Definition, fait passer les compiler passes, puis écrit le fichier PHP final. Tu ne l'utilises jamais à l'exécution.
Container compilé
Fichier PHP dans var/cache/{env}/App_KernelDevDebugContainer.php (ou similaire). Chargé à chaque requête — aucun parsing YAML, aucune réflexion. C'est pourquoi le DI Symfony est rapide.

Un objet Definition représente un service avant son instanciation. Il stocke : la classe, les arguments du constructeur, les méthodes à appeler après construction (addMethodCall), les tags, et des flags comme lazy, shared et public.

Les 5 phases de compilation

La compilation traverse cinq phases dans l'ordre défini par PassConfig. Quand tu enregistres un compiler pass, tu choisis dans quelle phase il s'exécute :

Constante PassConfigRôleExemple de pass Symfony
TYPE_BEFORE_OPTIMIZATION Lecture des attributs PHP, autoconfiguration, enregistrement des extensions RegisterAutoconfigureAttributesPass
TYPE_OPTIMIZE Résolution des références, autowiring, décoration, service locators AutowirePass, DecoratorServicePass
TYPE_BEFORE_REMOVING Dernier moment pour modifier des définitions avant le nettoyage Tes passes personnalisées (recommandé)
TYPE_REMOVE Suppression des services privés non référencés RemoveUnusedDefinitionsPass
TYPE_AFTER_REMOVING Post-traitement final, indexation, validation ResolveHotPathPass
⚠️ Piège : ne modifie jamais des définitions de services dans TYPE_AFTER_REMOVING. Les services non référencés ont déjà été supprimés — ton pass s'exécutera, mais les définitions qu'il cherche peuvent ne plus exister.

Plutôt que de regarder les phases défiler, mets-toi en situation. Tu écris un compiler pass qui collecte les services taggés app.handler pour les enregistrer dans un bus. Dans quelle phase faut-il l'exécuter ? Clique sur une phase pour voir la conséquence.

01 — ANATOMIE D'UN SERVICE

Anatomie d'un service

Avant de manier les outils du conteneur, une question simple mais décisive : qu'est-ce qu'un service, au juste — et qu'est-ce qui n'en est surtout pas un ?

Tout le cours tourne autour du conteneur de services. Mais un mot revient sans cesse sans jamais être défini : service. Avant d'aller plus loin, fixons-le, parce que la moitié des erreurs de conception viennent d'objets qu'on a mis dans le conteneur alors qu'ils n'avaient rien à y faire.

Un service
Un objet que le conteneur sait construire et qu'il partage. Par défaut, un service est un singleton : tu le demandes deux fois, tu reçois exactement la même instance. Son seul état légitime, ce sont ses dépendances — jamais de l'état métier qui changerait d'un appel à l'autre.

Cette règle du « pas d'état métier » n'est pas cosmétique. Comme l'instance est partagée, si un service stockait l'utilisateur courant ou le panier en cours, deux requêtes concurrentes se marcheraient dessus, et tes tests deviendraient imprévisibles. Un service fait ; il ne retient pas.

Ce qui n'est pas un service
Les données. Une entité Doctrine, un DTO, la requête HTTP courante, l'utilisateur connecté : ce sont des données, pas des services. Le conteneur est figé à la compilation — il ne peut pas fabriquer un objet différent à chaque requête. Tu peux avoir un service qui récupère l'utilisateur courant, mais l'utilisateur lui-même ne vit pas dans le conteneur.
🔑 Service ou classe statique ? On pourrait croire qu'une classe à méthodes statiques rend le même service. Non : une classe statique dépend de l'état global, impossible à remplacer pour un test. Un service, lui, n'a pour seul état que ses dépendances injectées — donc on peut les remplacer par des doublures. C'est toute la raison d'être du conteneur : ne plus dépendre de l'état global.

Public ou privé

Chaque service est soit public, soit privé. Depuis Symfony 4, ils sont privés par défaut, et c'est volontaire.

VisibilitéRécupérable via $container->get() ?Utilisable comme dépendance ?
publicOuiOui
private (défaut)Non — invisible de get()Oui
# services.yaml
App\Service\NewsletterDispatcher:
    public: true   # je veux le sortir du conteneur à la main

# tous les autres : privés, donc seulement injectables

Pourquoi « privé » rend le conteneur plus rapide

Le fait que la grande majorité des services soient privés ouvre deux optimisations, appliquées à la compilation — donc à coût d'exécution nul :

Suppression des services inutilisés
Un service privé que personne ne référence ne sera jamais récupérable (il n'est pas public) ni injecté (rien ne le demande). Il est donc retiré du conteneur compilé par RemoveUnusedDefinitionsPass.
Inlining des services à usage unique
Un service privé utilisé par un seul consommateur n'a pas besoin d'exister séparément : sa construction est recopiée directement dans son unique consommateur (InlineServiceDefinitionsPass). Un objet de moins à gérer, un appel de méthode de moins.
💡 Conséquence directe : tu peux enregistrer tout ton dossier src/ sans crainte (c'est l'objet de la section suivante). Les classes jamais utilisées disparaîtront du conteneur compilé : les déclarer ne coûte rien.
🔑 PSR-11. Le conteneur Symfony implémente Psr\Container\ContainerInterface, l'interface standard get() / has() partagée entre frameworks. Coder contre cette interface — quand tu as vraiment besoin d'un conteneur, comme dans un service locator — garde ton code indépendant de l'implémentation Symfony. (Injecter le conteneur entier dans un service métier reste un anti-pattern : voir la Synthèse.)

Le simulateur ci-dessous montre ce que la compilation fait d'un petit graphe de services. Bascule la visibilité, puis compile : observe qui est conservé, inliné ou supprimé.

Change la visibilité d'un service, puis clique sur Compiler.

02 — CONFIG IMPLICITE

La configuration implicite

Un projet Symfony ne déclare presque aucun service à la main. Voyons comment une config verbeuse fond jusqu'à quelques lignes — sans rien perdre au passage.

Au début d'un projet Symfony, on pourrait croire qu'il faut déclarer chaque service à la main : sa classe, ses arguments, sa visibilité. C'est possible, c'est même instructif de le faire une fois — mais personne ne travaille comme ça au quotidien. La configuration réelle d'une application moderne tient en quelques lignes. Voyons comment on y arrive, étape par étape.

Étape par étape, la config fond

On part d'une définition complète et explicite, et on retire à chaque étape ce que le conteneur peut deviner tout seul.

  1. Le FQCN comme identifiant. Plutôt qu'un ID inventé (app.mailer), on prend le nom de classe complet comme ID de service. Bonus : le conteneur en déduit la classe, on peut donc omettre la clé class:.
  2. L'autowiring. Le constructeur est déjà typé. Si un argument est typé Mailer et qu'il existe un service dont l'ID est Mailer, le conteneur les relie tout seul. Les arguments: disparaissent.
  3. Les alias d'interface. L'autowiring fait correspondre un type à un ID de service. Si une dépendance est typée par une interface, on crée un alias interface → implémentation. Et si une seule classe implémente l'interface, Symfony crée cet alias automatiquement.
  4. Le bloc _defaults. Plutôt que autowire: true sur chaque service, on le déclare une fois pour tout le fichier.
  5. L'auto-enregistrement resource:. On pointe un dossier, et le conteneur enregistre toutes les classes qu'il y trouve.
# config/services.yaml — l'état final, « la magie activée »
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'
        exclude: '../src/{Entity,DTO,Kernel.php}'
App\: resource: '../src/'
Le conteneur scanne récursivement src/, suppose le nommage PSR-4 (un fichier = une classe, le namespace suit l'arborescence) et enregistre chaque classe comme service, avec son FQCN pour ID. exclude: écarte ce qui n'est pas un service — typiquement les entités et les DTO (qui sont des données, cf. section précédente).
💡 « Mais ça enregistre des centaines de classes ! » Sans danger : on l'a vu, les services privés non utilisés sont supprimés du conteneur compilé. Enregistrer large ne coûte donc rien à l'exécution, et t'évite d'éditer le YAML chaque fois que tu ajoutes une classe.

Garder la main : surcharger une découverte

L'auto-enregistrement n'enlève rien à ton contrôle. Une classe découverte automatiquement peut être reconfigurée plus bas dans le fichier — la dernière définition gagne :

App\:
    resource: '../src/'

# … découverte automatiquement, mais elle a besoin d'un argument particulier :
App\Service\PaymentGateway:
    arguments:
        $apiKey: '%env(STRIPE_KEY)%'
_defaults est local au fichier
Le bloc _defaults ne s'applique qu'aux services définis dans le même services.yaml. Si tu éclates ta configuration en plusieurs fichiers, chacun doit redéclarer son propre _defaults.
🔑 L'essentiel : config explicite et config implicite produisent exactement le même conteneur compilé. L'implicite n'est pas une version « au rabais » — c'est la même chose, écrite plus court. Tout ce que tu as appris sur les Definition reste vrai dessous.

Le simulateur ci-dessous fait travailler l'autowiring sous tes yeux. Lance la résolution : le conteneur relie chaque paramètre typé à un service, et crée l'alias d'interface tout seul tant qu'il n'y a qu'une implémentation. Ajoute ensuite une seconde implémentation de LoggerInterface et relance — l'ambiguïté casse la résolution, et c'est précisément là que les attributs de la section suivante entrent en jeu.

NewsletterDispatcher(
  Mailer $mailer,
  LoggerInterface $logger,
)
03 — #[AUTOWIRE] AVANCÉ

#[Autowire] avancé

L'attribut #[Autowire] va bien au-delà de l'injection de service — il couvre les paramètres, les variables d'environnement, les expressions et la résolution d'ambiguïtés.

L'attribut #[Autowire] (namespace Symfony\Component\DependencyInjection\Attribute) est une annotation sur un paramètre de constructeur ou une propriété. Il résout exactement un argument — pas tous. C'est sa force : une résolution chirurgicale là où l'autowiring standard ne suffit pas.

Règle d'or
#[Autowire] accepte exactement l'un des arguments suivants : service, expression, env, param, ou value (une valeur directe). En passer deux à la fois lève une LogicException à la compilation.

Les variantes

Injecter un service par ID explicite

Utile quand deux services implémentent la même interface et que tu veux en cibler un précis.

use Symfony\Component\DependencyInjection\Attribute\Autowire;

public function __construct(
    #[Autowire(service: 'monolog.logger.security')]
    private readonly LoggerInterface $logger,
) {}

Injecter un paramètre de conteneur

#[Autowire(param: 'kernel.project_dir')]
private readonly string $projectDir

Injecter une variable d'environnement

#[Autowire(env: 'STRIPE_SECRET_KEY')]
private readonly string $stripeKey

Équivaut à %env(STRIPE_SECRET_KEY)% en YAML. Le processeur d'env est appliqué normalement.

Injecter une expression

#[Autowire(expression: 'service("router").generate("app_home")')]
private readonly string $homeUrl

Requiert le composant symfony/expression-language. La syntaxe courte '@=' fonctionne aussi : #[Autowire('@=service("router").generate("app_home")')].

Résoudre une ambiguïté avec #[Target]

Quand deux services du même type coexistent (ex. deux loggers), #[Target] résout l'alias nommé :

// services.yaml
App\Service\PaymentLogger:
    alias: App\Service\PaymentLogger $paymentLogger

// Dans ta classe
use Symfony\Component\DependencyInjection\Attribute\Target;

public function __construct(
    #[Target('paymentLogger')]
    private readonly LoggerInterface $logger,
) {}
🔑 À retenir : #[Target] convertit la chaîne en camelCase normalisé ('payment_logger''paymentLogger'). L'alias doit être déclaré dans la config ou via un autre service du même nom.

#[AutowireCallable] et #[AutowireServiceClosure]

Ces deux attributs permettent d'injecter des closures plutôt que des objets — utile pour les architectures lazy ou les callbacks.

#[AutowireCallable] — envelopper une méthode dans une Closure

#[AutowireCallable(service: 'mailer', method: 'send')]
private readonly \Closure $send

// Utilisation : ($this->send)($email);

#[AutowireServiceClosure] — wrapper un service dans une closure

#[AutowireServiceClosure('heavy.service')]
private readonly \Closure $heavyFactory

// Résolution différée : $service = ($this->heavyFactory)();
💡 Différence clé : #[AutowireCallable] produit une Closure qui appelle la méthode. #[AutowireServiceClosure] produit une Closure qui retourne le service. Avec #[AutowireServiceClosure], le service n'est instancié qu'à l'appel de la closure — c'est une lazy-injection sans proxy.

Essayer chaque source

Les cinq sources de #[Autowire] injectent des valeurs de natures très différentes. Clique pour voir ce qui atterrit dans le paramètre, et l'équivalent YAML — puis tente d'en combiner deux.

04 — TAGGED SERVICES

Tagged services — le patron Strategy du conteneur

Les tags permettent de regrouper des services pour les itérer — c'est le patron Strategy mis en œuvre par le conteneur.

Un tag est une étiquette apposée sur un service. Il signifie : "ce service appartient à ce groupe". Tous les services d'un même tag peuvent ensuite être itérés ensemble — c'est le pattern Strategy implémenté au niveau du conteneur.

Déclarer un tag sur une interface

L'approche recommandée : tagger l'interface, pas chaque implémentation. Toute classe qui l'implémente hérite du tag grâce à l'autoconfiguration.

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.payment_handler')]
interface PaymentHandlerInterface {}

// Toutes les classes implémentant cette interface reçoivent automatiquement le tag.
final class StripeHandler implements PaymentHandlerInterface {}
final class PaypalHandler implements PaymentHandlerInterface {}

Contrôler l'index et la priorité avec #[AsTaggedItem]

#[AsTaggedItem] s'applique sur la classe (pas l'interface). Il définit la clé sous laquelle le service est accessible dans l'itérateur/locator, et sa priorité dans l'ordre d'itération.

#[AsTaggedItem(index: 'stripe', priority: 10)]
final class StripeHandler implements PaymentHandlerInterface {}

#[AsTaggedItem(index: 'paypal', priority: 5)]
final class PaypalHandler implements PaymentHandlerInterface {}
Ordre de priorité
Les services sont triés par priorité décroissante (le plus haut en premier). Pour des priorités égales, l'ordre de déclaration est préservé (FIFO garanti — Symfony n'utilise pas SplPriorityQueue qui brise le FIFO).

Injecter l'itérateur avec #[AutowireIterator]

use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

public function __construct(
    #[AutowireIterator('app.payment_handler')]
    private readonly iterable $handlers,
) {}

public function process(): void
{
    foreach ($this->handlers as $handler) {
        $handler->handle();
    }
}

Itérateur indexé

Pour accéder aux services par leur clé (définie via #[AsTaggedItem(index:)]) :

#[AutowireIterator('app.payment_handler', indexAttribute: 'index')]
private readonly iterable $handlers

// foreach ($this->handlers as $key => $handler) { ... }
// $key vaut 'stripe' ou 'paypal'
🔑 Important : #[AutowireIterator] injecte un RewindableGenerator — tous les services sont instanciés immédiatement lors du premier parcours. Si tu veux une résolution lazy service par service, utilise un locator (section suivante).

Modifie les priorités ci-dessous pour voir comment l'ordre d'itération change :

Ajuste les priorités — l'ordre d'itération se recalcule automatiquement.

Ordre d'itération :

05 — SERVICE LOCATORS

Service locators — la résolution à la demande

Là où l'itérateur charge tous les services d'un coup, le locator les résout à la demande — c'est la différence entre un buffet et un serveur à la carte.

Un itérateur tagged instancie tous les services au premier parcours. Un service locator ne les instancie qu'à la demande — c'est sa différence fondamentale. Si tu as 10 handlers et n'en utilises qu'un par requête, le locator économise 9 instanciations.

#[AutowireLocator]

use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
use Psr\Container\ContainerInterface; // le locator implémente ServiceCollectionInterface

public function __construct(
    #[AutowireLocator('app.payment_handler')]
    private readonly ContainerInterface $handlers,
) {}

public function pay(string $method): void
{
    $handler = $this->handlers->get($method); // 'stripe', 'paypal'…
    $handler->process();
}
ServiceCollectionInterface
ServiceLocator implémente ServiceCollectionInterface (pas PSR-11 complet). Il expose get(string $id) et has(string $id). La clé est l'index défini par #[AsTaggedItem(index:)] ou, à défaut, le FQCN de la classe.

Locator sur liste explicite

Pas besoin de tags — on passe directement les types attendus :

#[AutowireLocator([
    StripeHandler::class,
    PaypalHandler::class,
])]
private readonly ContainerInterface $handlers

ServiceSubscriberInterface — l'alternative déclarative

Quand c'est la classe elle-même qui connaît ses dépendances lazy, elle peut implémenter ServiceSubscriberInterface pour les déclarer :

use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\Contracts\Service\ServiceSubscriberTrait;

final class PaymentProcessor implements ServiceSubscriberInterface
{
    use ServiceSubscriberTrait;

    public static function getSubscribedServices(): array
    {
        return [
            'stripe' => StripeHandler::class,
            'paypal' => '?'.PaypalHandler::class, // optionnel
        ];
    }
}
AutowireLocator
  • Dépendances déclarées à l'injection
  • Pratique pour les dispatchers génériques
  • La classe consommatrice ignore les types précis
ServiceSubscriberInterface
  • La classe déclare elle-même ses besoins
  • Plus explicite, plus testable
  • Adapté aux classes avec logique métier

Clique sur un handler ci-dessous pour le résoudre depuis le locator :

Clique sur get(handler) — seul ce handler est instancié.

06 — DÉCORATION

Décoration de services — l'oignon du conteneur

Décorer un service, c'est l'envelopper sans le modifier — comme empiler des couches sur un oignon.

La décoration, c'est envelopper un service existant dans un nouveau — sans modifier l'original, sans le sous-classer. Le consommateur final ne voit que l'enveloppe extérieure, qui délègue (ou non) à l'enveloppe suivante. Symfony gère tout le câblage.

#[AsDecorator]

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

#[AsDecorator(decorates: LoggerInterface::class, priority: 10)]
final class TimingLogger implements LoggerInterface
{
    public function __construct(
        #[AutowireDecorated]
        private readonly LoggerInterface $inner,
    ) {}

    public function log($level, string|\Stringable $message, array $context = []): void
    {
        $start = hrtime(true);
        $this->inner->log($level, $message, $context);
        $ms = (hrtime(true) - $start) / 1e6;
        $this->inner->debug("Log took {$ms}ms");
    }
}
#[AutowireDecorated]
Injette automatiquement le service décoré (alias .inner). Sans cet attribut, tu devrais référencer l'alias manuellement : #[Autowire(service: LoggerInterface::class.'.inner')].

Ordre de priorité dans une chaîne

Quand plusieurs décorateurs ciblent le même service, la priorité détermine l'ordre d'enveloppement. La plus haute priorité est l'enveloppe la plus extérieure — elle est appelée en premier.

Consommateur
TimingLogger
priority 10
ContextLogger
priority 5
MonologLogger
service original

Décorations multiples sur la même classe

#[AsDecorator] est IS_REPEATABLE — une classe peut décorer plusieurs services :

#[AsDecorator(decorates: LoggerInterface::class, priority: 10)]
#[AsDecorator(decorates: EventDispatcherInterface::class, priority: 5)]
final class ProfilingDecorator { ... }
💡 À retenir : onInvalid vaut par défaut ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE — si le service décoré n'existe pas, la compilation explose. Passe onInvalid: ContainerInterface::IGNORE_ON_INVALID_REFERENCE pour un décorateur optionnel (ex. actif uniquement en dev).

Coche les décorateurs pour visualiser la chaîne d'appels :

07 — #[AUTOCONFIGURE]

#[Autoconfigure] et instanceof

Plutôt que de tagger chaque service manuellement, on peut dire au conteneur « tout ce qui implémente cette interface reçoit automatiquement cette configuration ».

#[Autoconfigure] et la directive YAML _instanceof résolvent le même problème : appliquer automatiquement une configuration (tags, appels de méthodes, lazy) à toute une hiérarchie de classes. Plus besoin de tagger chaque implémentation à la main.

#[Autoconfigure] sur une interface

use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;

#[Autoconfigure(
    tags:  [['app.voter']],
    lazy:  true,
    calls: [['setLogger', ['@logger']]],
)]
interface VoterInterface {}

// Chaque classe implémentant VoterInterface reçoit automatiquement :
// - le tag 'app.voter'
// - lazy: true
// - un appel setLogger($logger) après construction
#[AutoconfigureTag] — le raccourci
Quand tu veux seulement ajouter un tag, #[AutoconfigureTag('nom')] est plus concis. Il est IS_REPEATABLE — tu peux en cumuler plusieurs sur la même interface. C'est un alias de #[Autoconfigure(tags: [['nom']])].

_instanceof en YAML — l'équivalent déclaratif

# config/services.yaml
services:
    _instanceof:
        App\Domain\VoterInterface:
            tags: ['app.voter']
            lazy: true

Équivalent exact de #[Autoconfigure] — l'un ou l'autre, pas les deux pour la même interface (ils se cumulent sans conflit, mais c'est redondant).

Enregistrement conditionnel : #[When] et #[WhenNot]

Un service n'est enregistré que si la condition est vraie :

use Symfony\Component\DependencyInjection\Attribute\When;
use Symfony\Component\DependencyInjection\Attribute\WhenNot;

#[When(env: 'dev')]
final class DebugMailer implements MailerInterface {}

#[WhenNot(env: 'test')]
final class RealMailer implements MailerInterface {}
🔑 À retenir : #[Autoconfigure] est traité par RegisterAutoconfigureAttributesPass en phase TYPE_BEFORE_OPTIMIZATION, avant tout le reste. Une configuration posée sur une interface s'applique à toutes les classes concrètes qui l'implémentent — même celles dans des bundles tiers, si elles sont dans ton répertoire de services.
⚠️ Piège : #[Autoconfigure] ne fonctionne que si le service est autoconfigured (c'est le cas par défaut). Un service déclaré manuellement en YAML avec autoconfigure: false ignore les attributs #[Autoconfigure].

Voir la configuration se propager

L'interface VoterInterface porte un #[Autoconfigure]. Chaque implémentation en hérite — sauf si elle a autoconfigure: false. Bascule l'option, puis compile.

#[Autoconfigure] sur l'interface → tag app.voter + lazy: true + setLogger().

08 — SERVICES LAZY

Services lazy — l'instanciation différée

Un service lazy n'est instancié que la première fois qu'on l'utilise — idéal pour les services lourds rarement appelés.

Un service "lazy" n'est pas instancié quand il est injecté dans un consommateur — il l'est seulement à la première vraie utilisation. Symfony insère un objet fantôme (ou un proxy) à sa place. Si le consommateur ne l'utilise jamais, le service n'est jamais construit.

#[Lazy] — les deux cibles

Sur la classe — tous les consommateurs reçoivent un proxy

use Symfony\Component\DependencyInjection\Attribute\Lazy;

#[Lazy]
final class HeavyReportService
{
    public function __construct(private readonly Connection $db) {}
}

Sur un paramètre — seulement ce point d'injection est lazy

public function __construct(
    #[Lazy]
    private readonly HeavyReportService $reports,
) {}

// Ou via #[Autowire] :
#[Autowire(lazy: true)]
private readonly HeavyReportService $reports

Ghost objects vs virtual proxies

Symfony choisit automatiquement le mécanisme selon la classe :

Ghost object (PHP 8.4 natif, Symfony ≥ 7.2)
Une instance de la vraie classe, créée via ReflectionClass::newLazyGhost(). Aucune sous-classe générée. Plus léger — c'est le choix par défaut quand la classe n'est pas final et n'a pas d'interface spécifique requise.
Virtual proxy
Une sous-classe générée qui délègue à la vraie instance dès la première appel. Utilisé quand la classe est final ou quand tu spécifies une interface : #[Autowire(lazy: SomeInterface::class)] — le proxy implémente cette interface, permettant de l'injecter là où seule l'interface est typée.
// Ghost object — classe concrète, PHP 8.4+
#[Lazy]
class ReportService {}

// Virtual proxy — interface imposée
#[Autowire(lazy: ReportServiceInterface::class)]
private readonly ReportServiceInterface $reports
#[Autowire(lazy: true)] — le choix automatique
Quand tu passes lazy: true sans spécifier d'interface, Symfony choisit automatiquement :
  • Ghost object si la classe n'est pas final (PHP 8.4+)
  • Virtual proxy si la classe est final ou si aucun mécanisme ghost n'est disponible
Spécifier une interface (lazy: MonInterface::class) force toujours un virtual proxy.
💡 Cas d'usage légitimes : services avec connexions réseau à la construction (clients SOAP, connexions AMQP), dépendances circulaires réelles (à éviter, mais parfois incontournables), services de debug non utilisés en prod.
Ne pas abuser : chaque ghost object a un overhead léger mais réel. Ne mets pas #[Lazy] sur tout "par précaution".

Observe la différence d'instanciation ci-dessous :

09 — COMPILER PASSES

Compiler passes — greffer sur le compilateur

Un compiler pass est un greffon sur le compilateur du conteneur — il peut lire, modifier et enrichir les définitions avant que le conteneur soit figé.

Un compiler pass s'insère dans le processus de compilation du conteneur pour lire, modifier ou enrichir des définitions. C'est l'outil de choix pour les architectures extensibles : enregistrer automatiquement des handlers, remplacer un service, ou construire un registry.

Structure d'un compiler pass

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

final class RegisterHandlersPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        if (!$container->has('app.command_bus')) {
            return; // service optionnel : sortir proprement
        }

        $bus = $container->getDefinition('app.command_bus');

        foreach ($container->findTaggedServiceIds('app.command_handler') as $id => $tags) {
            foreach ($tags as $tag) {
                $bus->addMethodCall('register', [new Reference($id), $tag['command'] ?? null]);
            }
        }
    }
}

Enregistrer un pass dans le Kernel ou un Bundle

// src/Kernel.php
protected function build(ContainerBuilder $container): void
{
    $container->addCompilerPass(
        new RegisterHandlersPass(),
        PassConfig::TYPE_BEFORE_OPTIMIZATION, // phase
        0                                         // priorité dans la phase
    );
}
findTaggedServiceIds(string $tag)
Retourne array<serviceId, array<tagAttributes>>. Un service peut avoir plusieurs occurrences du même tag (ex. deux #[AsTaggedItem] sur la même classe). Itère sur les attributs avec le foreach interne.

Opérations courantes dans process()

OpérationMéthode
Lire une définition$container->getDefinition('service.id')
Vérifier l'existence$container->has('service.id')
Ajouter un appel de méthode$def->addMethodCall('register', [new Reference($id)])
Remplacer un argument$def->replaceArgument(0, new Reference('autre'))
Supprimer un service$container->removeDefinition('service.id')
Chercher des services taggés$container->findTaggedServiceIds('mon.tag')
💡 PriorityTaggedServiceTrait : les compiler passes du core Symfony utilisent ce trait pour récupérer et trier les services taggés en une seule opération. Si tu veux le même comportement, fais-le implémenter par ton pass : use PriorityTaggedServiceTrait; puis appelle $this->findAndSortTaggedServices('mon.tag', $container).

Simule un compiler pass sur un mini-conteneur :

3 handlers taggés app.command_handler. Clique sur process().

10 — BINDINGS & FACTORIES

Bindings, factories et ChildDefinition

Quand l'autowiring ne suffit pas, trois outils prennent le relais : les bindings pour forcer un argument, les factories pour déléguer la construction, et l'héritage de définition pour factoriser.

Trois mécanismes couvrent les cas où l'autowiring standard ne suffit pas : les bindings pour forcer un argument sur un ensemble de services, les factories pour déléguer la construction, et ChildDefinition pour factoriser des définitions similaires.

Bindings — forcer un argument par namespace

Un binding associe un nom ou un type d'argument à une valeur, pour tous les services d'un namespace donné.

# config/services.yaml
services:
    App\:
        resource: '../src/'
        bind:
            # par nom de paramètre
            $apiKey: '%env(STRIPE_KEY)%'
            # par type
            Psr\Log\LoggerInterface $logger: '@monolog.logger.payment'
Binding vs #[Autowire]
Un binding s'applique à tous les services du namespace — il est global. #[Autowire] s'applique à un seul paramètre — il est local et précis. Préfère #[Autowire] pour la clarté. Réserve bind: aux conventions transversales (ex. l'API key injectée partout dans le domaine Payment).

Factory services — déléguer la construction

Quand la construction d'un service nécessite une logique (ex. choisir l'implémentation selon la config), une factory prend en charge l'instanciation.

# config/services.yaml
App\Service\Logger:
    factory: ['App\Factory\LoggerFactory', 'create']
    arguments: ['%env(LOG_CHANNEL)%']
// Équivalent PHP dans un compiler pass :
use Symfony\Component\DependencyInjection\Definition;

$def = new Definition(Logger::class);
$def->setFactory([LoggerFactory::class, 'create']);
$def->setArguments(['%env(LOG_CHANNEL)%']);
$container->setDefinition(Logger::class, $def);

Configurateurs — post-construction complexe

Un configurateur est appelé après la construction du service pour finaliser son état. Alternative aux calls: quand la logique est trop complexe pour être inline.

# services.yaml
App\Service\ReportGenerator:
    configurator: ['@App\Config\ReportConfigurator', 'configure']

ChildDefinition — héritage de définition

Quand plusieurs services partagent les mêmes arguments de base, un service parent abstrait factorise la configuration :

# services.yaml
app.abstract_handler:
    abstract: true
    arguments: ['@logger', '@event_dispatcher']

App\Handler\CreateUserHandler:
    parent: app.abstract_handler
    arguments: ['@user_repository'] # argument supplémentaire

App\Handler\DeleteUserHandler:
    parent: app.abstract_handler
🔑 À retenir : ChildDefinition hérite de tous les arguments et appels du parent. Les arguments supplémentaires sont ajoutés à ceux du parent. Pour remplacer un argument hérité, utilise l'index : arguments: { index_0: '@autre_logger' }.
⚠️ Quand éviter ChildDefinition : si chaque enfant a des arguments très différents, l'héritage devient trompeur. Préfère une interface commune + autowiring dans ce cas. ChildDefinition brille pour des services presque identiques (ex. plusieurs clients HTTP ou plusieurs connexions Doctrine avec des options communes).

Voir l'héritage de ChildDefinition se résoudre

Le parent abstrait app.abstract_handler définit les arguments [@logger, @event_dispatcher]. Clique sur un enfant pour voir la liste d'arguments qu'il obtient vraiment — hérités, ajoutés, ou remplacés par index.

11 — SYNTHÈSE

Synthèse — le guide de choix

Le conteneur DI de Symfony est un langage en soi — voici le dictionnaire de choix.

Le conteneur DI de Symfony offre une boîte à outils riche. La difficulté n'est pas de savoir comment utiliser chaque outil — c'est de savoir lequel choisir face à un besoin. Voici le guide de choix.

Guide de choix

BesoinOutil
Injecter tous les services d'un type, en une seule fois #[AutowireIterator]
Résoudre un service parmi plusieurs, à la demande #[AutowireLocator]
Ajouter un comportement à un service sans le modifier #[AsDecorator]
Configurer automatiquement toute une hiérarchie #[Autoconfigure] / _instanceof
Transformer le conteneur globalement (registries, rewiring) Compiler pass
Forcer un argument sur tout un namespace bind: (YAML) ou setBindings()
Construire un service via une logique applicative Factory service
Factoriser des définitions quasi-identiques ChildDefinition (parent abstrait)
Différer l'instanciation d'un service lourd #[Lazy]
Résoudre une ambiguïté d'autowiring entre deux services du même type #[Target]

Anti-patterns à éviter

🚫 Injecter ContainerInterface dans un service métier.
C'est du service locator abuse. La classe devient opaque : on ne sait plus de quelles dépendances elle a besoin. Injecte les services directement, ou utilise #[AutowireLocator] avec une interface claire.
🚫 Modifier des définitions dans TYPE_AFTER_REMOVING.
Les services non référencés ont déjà été supprimés. Ton pass peut trouver des définitions manquantes. Utilise TYPE_BEFORE_OPTIMIZATION ou TYPE_BEFORE_REMOVING.
🚫 #[Lazy] sur tout "par précaution".
Chaque ghost object ou proxy a un overhead de création. Réserve #[Lazy] aux services qui initialisent des ressources lourdes (connexions réseau, lecture de fichiers) à la construction.
🚫 Un compiler pass qui enregistre dans TYPE_OPTIMIZE sans priorité explicite.
La phase TYPE_OPTIMIZE contient une vingtaine de passes Symfony (autowiring, décoration…). Sans priorité, ton pass peut s'exécuter avant que les définitions soient complètes. Utilise TYPE_BEFORE_OPTIMIZATION ou spécifie une priorité négative.

Récapitulatif des attributs

AttributCible PHPRôle
#[Autowire]Paramètre, propriétéRésoudre un argument précis
#[Target]Paramètre, propriétéLever une ambiguïté d'autowiring
#[AutowireIterator]ParamètreInjecter un itérateur tagged
#[AutowireLocator]ParamètreInjecter un service locator
#[AutowireCallable]ParamètreInjecter une Closure wrappant une méthode
#[AutowireServiceClosure]ParamètreInjecter une Closure retournant un service
#[Lazy]Classe, paramètreService ou injection lazy
#[AsDecorator]Classe (IS_REPEATABLE)Déclarer un décorateur
#[AutowireDecorated]Paramètre, propriétéInjecter le service décoré (.inner)
#[Autoconfigure]Classe (IS_REPEATABLE)Configuration d'une hiérarchie
#[AutoconfigureTag]Classe (IS_REPEATABLE)Tag automatique sur une hiérarchie
#[AsTaggedItem]Classe (IS_REPEATABLE)Index et priorité dans les itérateurs/locators
#[When] / #[WhenNot]ClasseEnregistrement conditionnel par env
#[AsAlias]ClasseAlias de service