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.
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.
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 PassConfig | Rôle | Exemple 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 |
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.
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 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.
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.
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 ? |
|---|---|---|
| public | Oui | Oui |
| 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 :
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.
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.
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\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.
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.
- 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:. - L'autowiring. Le constructeur est déjà typé. Si un argument est typé
Maileret qu'il existe un service dont l'ID estMailer, le conteneur les relie tout seul. Lesarguments:disparaissent. - 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. - Le bloc
_defaults. Plutôt queautowire: truesur chaque service, on le déclare une fois pour tout le fichier. - 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).
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 fichierLe 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.
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.
Mailer $mailer,
LoggerInterface $logger,
)
#[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.
#[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,
) {}
#[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)();
#[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.
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 {}
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'
#[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 :
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();
}
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
];
}
}
- Dépendances déclarées à l'injection
- Pratique pour les dispatchers génériques
- La classe consommatrice ignore les types précis
- 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é.
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");
}
}
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.
priority 10
priority 5
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 { ... }
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 :
#[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
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 {}
#[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.
#[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().
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 :
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.
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
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
finalou si aucun mécanisme ghost n'est disponible
lazy: MonInterface::class) force toujours un virtual proxy.
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 :
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
);
}
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ération | Mé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') |
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().
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'
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
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' }.
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.
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
| Besoin | Outil |
|---|---|
| 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
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.
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.
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.
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
| Attribut | Cible PHP | Rô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ètre | Injecter un itérateur tagged |
#[AutowireLocator] | Paramètre | Injecter un service locator |
#[AutowireCallable] | Paramètre | Injecter une Closure wrappant une méthode |
#[AutowireServiceClosure] | Paramètre | Injecter une Closure retournant un service |
#[Lazy] | Classe, paramètre | Service 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] | Classe | Enregistrement conditionnel par env |
#[AsAlias] | Classe | Alias de service |