Aller au contenu principal

L'injection de dépendance, ou comment être fainéant avec élégance

Autowiring, services tagués, #[Required] : ce que le container Symfony fabrique à votre place, et ce que ça coûte quand le process ne meurt plus entre deux requêtes.

MAJ 10 min de lecture
Sommaire · 7

Le container Symfony fabrique les services à notre place, et on a raison de le laisser faire. Mais ce qu'il nous donne, ce n'est pas un objet : c'est une durée de vie. Tant que le process meurt à la fin de la requête, personne ne s'en aperçoit.

La fainéantise organisée

Je n'écris plus new depuis longtemps. Les services de ce blog déclarent ce dont ils ont besoin dans leur constructeur, et le container le leur apporte. C'est assez confortable pour qu'on cesse de se demander ce qu'on lui a délégué au juste.

En mai 2026, une extension Twig de ce blog grossissait en mémoire d'une requête à l'autre. Le fichier n'avait pas bougé depuis février 2025, et il était correct ce jour-là. Ce qui avait changé, c'est que le process avait arrêté de mourir.

Ce billet fait donc deux choses. Il montre comment on délègue au container la fabrication des services, avec le code de ce blog plutôt que des exemples de brochure. Puis il regarde le prix de cette délégation quand l'application tourne en worker mode.

La fainéantise reste le réflexe à garder. Elle a juste une facture, et elle ne figure pas sur le devis.

Ce que le container fait à notre place

Le principe tient en une ligne : on ne fabrique pas ses collaborateurs, on les déclare. Un controller de ce blog, en entier pour la partie qui nous intéresse :

PHP
// src/Controller/Content/CategoryAction.php
final class CategoryAction extends AbstractController
{
    use PaginatesListingTrait;

    private const int POSTS_PER_PAGE = 12;

    public function __construct(
        private readonly PostService $postService,
        private readonly PostStatisticsProvider $postStatisticsProvider,
    ) {}

    // ... __invoke() ...
}

Aucun new nulle part. Aucune ligne de configuration non plus : Symfony lit les types du constructeur et va chercher les services correspondants. C'est l'autowiring, et c'est la seule chose qu'il fait.

Ce qui se joue là est moins spectaculaire qu'on ne le raconte, et plus utile. En écrivant private readonly PostService $postService, on déclare un besoin. En écrivant new PostService(...), on déclarerait un besoin plus la façon de le satisfaire, plus la liste des dépendances de PostService, plus celles de ses propres dépendances. Le container absorbe cette cascade.

Le readonly n'est pas décoratif. Sur ce blog, les services sont final readonly class par convention, et on verra plus bas que ce mot-clé fait office de garde-fou d'exécution.

Collecter des services sans écrire de CompilerPass

Le cas suivant est celui où l'injection cesse d'être une commodité pour devenir de l'architecture : on veut tous les services qui implémentent une interface, sans savoir combien il y en a.

Le bloc « À lire » de la home agrège quatre signaux (récence, pépite SEO, trending interne, reach). Chacun est un service. Le contrat vit sur l'interface, et le tag aussi :

PHP
// src/Service/Home/ReadingList/Scoring/ScoreComponentInterface.php
#[AutoconfigureTag('app.reading_list.score_component')]
interface ScoreComponentInterface
{
    public function score(Post $post, ScoringContext $context): float;

    public function getWeight(): float;

    public function getName(): string;
}

Une implémentation n'a donc rien à déclarer. Elle implémente, elle est taguée :

PHP
// src/Service/Home/ReadingList/Scoring/RecencyScoreComponent.php
final readonly class RecencyScoreComponent implements ScoreComponentInterface
{
    // ... rien de plus : ni tag, ni entrée de configuration ...
}

Et le collecteur les reçoit toutes :

PHP
// src/Service/Home/ReadingList/Scoring/PostScorer.php
final readonly class PostScorer
{
    /**
     * @param iterable<ScoreComponentInterface> $components
     */
    public function __construct(
        #[AutowireIterator('app.reading_list.score_component')]
        private iterable $components,
    ) {}

    // ... score() agrège en pondérant par getWeight() ...
}

Le bénéfice se mesure au moment où on ajoute un cinquième signal : une classe, zéro ligne de configuration, zéro fichier à ne pas oublier. PostScorer normalise par la somme effective des poids, donc retirer un component ne casse pas l'échelle du score. Le même motif sert au registre de blocs de l'éditeur, sur app.block_definition.

Pendant des années, ce ramassage demandait un CompilerPass : une classe qui parcourt findTaggedServiceIds() à la compilation du container et empile des addMethodCall() sur le registre. Une vingtaine de lignes, plus l'enregistrement dans le kernel qu'on oublie une fois sur deux, et qui fait que rien ne se passe sans que rien n'échoue non plus. Les tutoriels qui l'enseignent sont encore les premiers résultats de recherche. Ce blog n'en contient aucun.

Le CompilerPass garde son usage quand on manipule des définitions de services, pas des instances. Pour ramasser des services tagués, #[AutowireIterator] couvre le cas, et #[AsTaggedItem] ajoute l'index et la priorité si on en veut.

« Partagé » ne veut pas dire « Singleton »

On arrive au point que la doc énonce sans le commenter : par défaut, le container ne fabrique un service qu'une fois et rend la même instance à tous ceux qui la demandent. Le mot officiel est shared.

Ce n'est pas le pattern Singleton. Il n'y a pas de getInstance(), la classe n'a rien à savoir, c'est le container qui décide. La confusion est courante et elle est bénigne en PHP-FPM, où l'instance partagée dure le temps d'une requête. On partage un objet avec soi-même pendant trente millisecondes.

En worker mode, le kernel est démarré une fois et sert des milliers de requêtes. La même instance répond alors à des visiteurs qui n'ont rien à voir entre eux, pendant des heures. Tout ce qu'un service écrit dans ses propriétés reste là pour le suivant.

C'est la tasse à café du bureau. Tant que chacun rince la sienne avant de partir, la partager ne coûte rien à personne. Le problème commence le jour où plus personne ne part.

Voici la nôtre. Une extension Twig de ce blog traduit un nom court de controller en nom de classe complet, et comme la résolution coûte un parcours de toutes les routes du projet, elle mémoïse :

PHP
// src/Twig/Extension/RoutingExtension.php
final class RoutingExtension implements ResetInterface
{
    private ?array $controllers = null;

    private array $resolvedControllers = [];

    // ... cent lignes de résolution de routes ...

    public function reset(): void
    {
        $this->controllers = null;
        $this->resolvedControllers = [];
    }
}

Deux choses à regarder. La classe est final et pas final readonly, contrairement à la convention du reste du repo : c'est le signal visible qu'elle porte de l'état. Et elle implémente ResetInterface, que Symfony tague automatiquement en kernel.reset. Ce reset(), c'est le rinçage, et c'est le framework qui le passe entre deux requêtes parce que le process ne le fait plus.

Ces deux tableaux étaient un cache de requête quand le fichier a été écrit, en février 2025. Ils étaient corrects ce jour-là, et aucune relecture ligne à ligne ne les aurait sauvés : le bug n'est pas dans la classe, il est dans l'écart entre ce que la classe suppose et ce que le serveur fait. Le reset() est arrivé le 21 mai 2026, remonté par un analyseur statique dédié au worker mode. J'ai raconté cette chasse et ce qu'elle dit des patterns dans Les design patterns supposent que le process va mourir.

Ce qui compte ici est plus étroit. Le container ne nous donne pas un service, il nous donne une durée de vie. Elle valait trente millisecondes, elle vaut maintenant des heures, et rien dans le code appelant ne le dit.

Trois réglages qui sont des décisions de durée de vie

La doc range #[Required], lazy et shared: false dans les options avancées. Vus sous cet angle, ce sont trois façons de reprendre la main sur le cycle de vie.

#[Required] injecte après le constructeur. Utile quand un trait a besoin d'un service que les classes qui l'utilisent ne déclarent pas. Ce blog en contient exactement un usage, et son commentaire vaut mieux qu'un exemple inventé :

PHP
// src/Controller/Admin/Crud/Concerns/ContentBatchActionsTrait.php
private AdminUrlGenerator $adminUrlGenerator;

/**
 * Setter injection for a service the trait needs but the using controllers
 * don't declare in their constructor. Must be the #[Required] attribute, not
 * a `@required` docblock: Symfony 7 dropped docblock support, which left this
 * property uninitialized and 500ed every batch action after the flush (#1378).
 */
#[Required]
public function autowireContentBatchActionsTrait(AdminUrlGenerator $adminUrlGenerator): void
{
    $this->adminUrlGenerator = $adminUrlGenerator;
}

Symfony 7 a retiré le support du docblock @required. La propriété est restée non initialisée, et toutes les actions par lot de l'admin rendaient un 500 après le flush. Une injection par constructeur aurait échoué au boot, bruyamment. L'injection par setter échoue au premier appel, en production. C'est le prix de la souplesse, et il se paye rarement mais entièrement.

lazy retarde l'instanciation derrière un proxy, pour un service lourd qu'on n'appelle pas toujours. shared: false rend une instance neuve à chaque injection, pour un objet qui porte de l'état par nature.

Ce blog n'utilise ni l'un ni l'autre, et je préfère le dire que fabriquer un exemple. Il y a une raison à cette absence, et elle éclaire le reste : jusqu'à PHP 8.4, lazy ne savait pas proxifier une classe final ou readonly. La convention final readonly class fermait donc la porte. Les lazy objects natifs de PHP 8.4 ont levé la limite, et Symfony s'appuie dessus. La question se posera peut-être ici un jour ; elle ne s'est jamais posée jusqu'à présent.

Ce que les tests ne peuvent pas voir

L'injection par constructeur a une conséquence qu'on cite toujours et qu'on montre rarement : un service devient instanciable à la main. Le test du scorer n'a besoin ni du container, ni d'un framework de doublures :

PHP
// tests/Unit/Service/Home/ReadingList/Scoring/PostScorerTest.php
test('weighted aggregation', function (): void {
    // 2 components : a (poids 0.6, score 0.8), b (poids 0.4, score 0.2)
    $scorer = new PostScorer([
        ($this->fakeComponent)('a', 0.6, 0.8),
        ($this->fakeComponent)('b', 0.4, 0.2),
    ]);

    $result = $scorer->score(new Post(), postScorerContext());

    expect($result->score)->toEqualWithDelta(0.56, 1e-9);
});

Deux classes anonymes passées au constructeur, et l'agrégation se vérifie à la décimale. C'est là que l'injection de dépendance se rembourse : la testabilité, pas l'élégance.

Reste ce que ce test ne verra jamais. Il construit un PostScorer neuf, l'interroge, le jette. Chaque cas repart d'un état vierge, par conception, parce que c'est ce qu'on demande à une suite de tests. Elle est donc structurellement aveugle à la seule classe de bugs qui exige deux requêtes dans le même process pour se manifester.

D'où l'outillage. Un analyseur dédié au worker mode a rendu 269 signalements bruts sur ce blog, pour 3 vrais bugs après tri. Le ratio est mauvais, le tri est long, et c'est quand même la seule façon de trouver ces bugs, comme raconté dans Igor PHP : voir ce que PHPStan ne voit pas en worker mode.

La facture arrive quinze mois plus tard

La fainéantise organisée reste un excellent calcul. On délègue l'instanciation, le câblage, la collecte des services tagués, et on récupère du code qu'on peut tester sans container. Rien de tout ça n'est à regretter, et je ne reviendrais pour rien au monde à un new par dépendance.

Ce qu'on délègue en même temps, sans le lire, c'est la question de savoir combien de temps l'objet va vivre. Pendant vingt-cinq ans, PHP a répondu à notre place : le temps de la requête. La réponse était si constante qu'elle a cessé d'être une réponse pour devenir un décor. On a bâti des services, des patterns et des habitudes entières sur une garantie que plus personne ne formulait.

Les runtimes persistants ont retiré le décor sans prévenir. Ce qui reste, ce sont des milliers de classes écrites sous une hypothèse dont plus personne ne tient le registre. Ce blog en avait au moins une, et il a fallu quinze mois pour s'en apercevoir. Il n'y a personne pour rincer les tasses.

Et sur votre propre application : si elle tourne en worker mode, combien de vos services ont été écrits avant que vous le sachiez ?

Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).

Vous aimerez aussi

  1. Symfony · 9 min ·

    Attributs PHP : la moitié qu'on oublie d'écrire

    Découvrez les attributs PHP : déclaration et interprétation. Cet article compare cette nouveauté aux anciennes annotations et détaille leur fonctionnement.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques via Google Analytics (GA4) : pages vues, source du trafic, navigateur et interactions clés. Dépose des cookies de mesure, activés seulement avec votre accord (Consent Mode). Sans publicité ciblée, sans Google Signals, sans partage commercial.

Contenus externes

Affiche les GIF animés hébergés par Giphy (CDN aux États-Unis). À l'affichage d'un GIF, votre adresse IP et votre navigateur sont transmis à Giphy. Sans votre accord, les GIF ne s'affichent pas.