Skip to Content

Chapitre 5.1 — Le conteneur & l’autowiring

Partie 5 : Services & DI — Chapitre 1/5 Version de référence : Symfony 7.4.

⏱️ TL;DR

  • Un service est un objet qui fait un travail (envoyer un e-mail, créer une réservation) et vit dans le conteneur de services.
  • Injection de dépendances (DI) : une classe déclare ses dépendances dans son constructeur ; le conteneur les lui fournit. On ne fait jamais new d’un service.
  • Autowiring : Symfony lit les types des arguments du constructeur et injecte automatiquement le bon service. Zéro configuration dans le cas courant.
  • Injectez des interfaces, pas des classes concrètes : code découplé, testable, remplaçable.
  • Par défaut, config/services.yaml auto-enregistre tout src/ comme services, avec autowire: true et autoconfigure: true.
  • Un service est un singleton (une instance partagée par requête).
  • Diagnostic : debug:autowiring (quels types sont injectables), debug:container (tous les services).
  • Pour un dev Next.js : c’est NestJS@Injectable() + injection par constructeur — mais l’autowiring évite même de déclarer les providers.

🎯 Objectifs

  • Comprendre le rôle du conteneur et le principe de la DI.
  • Créer un service et le faire autowirer dans un contrôleur.
  • Injecter une interface avec une seule implémentation.
  • Diagnostiquer l’autowiring en ligne de commande.

1. Le problème que la DI résout

Sans injection, une classe crée ses dépendances — et se couple à elles :

// ❌ couplage fort : impossible à tester, à remplacer final class BookingCreator { public function create(CreateBookingInput $input): Booking { $mailer = new SmtpMailer('smtp://localhost'); // dépendance créée en dur $logger = new FileLogger('/var/log/app.log'); // idem // ... } }

Impossible de tester BookingCreator sans un vrai serveur SMTP, ni de remplacer le mailer. La DI inverse ça : la classe reçoit ses dépendances.

// ✅ dépendances injectées : testable, découplé final class BookingCreator { public function __construct( private readonly MailerInterface $mailer, private readonly LoggerInterface $logger, ) {} public function create(CreateBookingInput $input): Booking { // $this->mailer, $this->logger sont fournis « de l'extérieur » } }

En test, on injecte un faux mailer ; en prod, le vrai. La classe ne change pas. C’est le fondement de tout code Symfony maintenable.

💡 Pour un dev Next.js : si vous avez fait du NestJS, c’est identique : constructor(private mailer: MailerService) + @Injectable(). Si vous venez d’Express, c’est le passage du « j’importe et j’instancie mes utilitaires dans chaque fichier » au « je déclare mes besoins, un conteneur me les donne ». Le gain : testabilité et découplage.

2. Le conteneur de services

Le conteneur est un objet qui sait construire tous les services de l’application et résoudre leurs dépendances. Quand un contrôleur demande un BookingCreator, le conteneur :

  1. regarde de quoi BookingCreator a besoin (son constructeur : MailerInterface, LoggerInterface) ;
  2. construit (ou récupère) ces dépendances (elles-mêmes ont peut-être des dépendances…) ;
  3. instancie BookingCreator avec elles ;
  4. le réutilise (singleton) pour les demandes suivantes.

Ce graphe peut être profond (un service dépend d’un service qui dépend de trois autres…). Le conteneur le résout entièrement et de façon paresseuse (les services ne sont construits que lorsqu’on en a besoin). En prod, ce câblage est compilé une fois (dans var/cache/) → aucune surcharge à l’exécution.

3. L’autowiring : le câblage sans effort

L’autowiring est la fonctionnalité qui lit les types des arguments et devine le service à injecter. Vous n’avez, dans l’immense majorité des cas, rien à configurer : il suffit de typer vos dépendances.

// src/Service/SlotAvailabilityChecker.php declare(strict_types=1); namespace App\Service; use App\Repository\BookingRepository; use Psr\Log\LoggerInterface; final class SlotAvailabilityChecker { public function __construct( private readonly BookingRepository $bookings, // autowired private readonly LoggerInterface $logger, // autowired ) {} public function isAvailable(int $slotId, int $seats): bool { // ... return true; } }

Ce service est immédiatement injectable dans un contrôleur ou un autre service, sans aucune ligne de config :

#[Route('/api/slots/{id}/availability', methods: ['GET'])] public function availability(int $id, SlotAvailabilityChecker $checker): JsonResponse { return $this->json(['available' => $checker->isAvailable($id, 1)]); }

C’est ça, la « magie » — qui n’en est pas une : Symfony lit les type-hints et relie les points.

4. La config qui rend tout ça automatique

Comment SlotAvailabilityChecker est-il devenu un service sans qu’on le déclare ? Grâce à config/services.yaml, généré à la création du projet :

# config/services.yaml services: _defaults: autowire: true # injecte automatiquement les dépendances par type autoconfigure: true # tague automatiquement (commandes, subscribers…) # Enregistre TOUTES les classes de src/ comme services App\: resource: '../src/' exclude: - '../src/DependencyInjection/' - '../src/Entity/' - '../src/Kernel.php'

Traduction : toute classe de src/ (sauf les entités et quelques exclusions) est automatiquement un service, autowiré et autoconfiguré. Vous créez une classe dans src/Service/, elle est instantanément injectable. Les entités sont exclues (ce sont des données, pas des services — Partie 6).

Depuis Symfony 5 : cette config par défaut est stable, mais l’usage des attributs (#[Autowire], #[AsEventListener], #[AutoconfigureTag]) pour les cas particuliers a largement remplacé le YAML verbeux. On configure de moins en moins dans services.yaml ; on annote directement les classes.

5. La règle d’or : injecter des interfaces

Quand vous dépendez d’un comportement, dépendez de son interface, pas de la classe concrète :

// ✅ dépend du contrat, pas de l'implémentation public function __construct(private readonly MailerInterface $mailer) {} // ❌ dépend d'une implémentation précise → couplage public function __construct(private readonly SmtpMailer $mailer) {}

Pourquoi ? Parce que l’implémentation peut changer (SMTP → API → faux mailer en test) sans toucher votre code. Symfony autowire une interface s’il n’existe qu’une seule implémentation (il fait le lien automatiquement). S’il y en a plusieurs, on précise laquelle (chapitre 5.2).

⚠️ Piège : « ça marche avec la classe concrète, pourquoi s’embêter avec l’interface ? » — parce que le jour où vous voulez tester (injecter un double) ou remplacer l’implémentation, le couplage à la classe concrète vous bloque. Prendre l’habitude d’injecter des interfaces coûte peu et rapporte gros. C’est le réflexe de découplage de Symfony.

6. Les services sont des singletons

Par défaut, le conteneur crée une seule instance de chaque service et la partage partout où elle est injectée, pour toute la durée de la requête. C’est ce qu’on veut presque toujours (un service est sans état propre ; il opère sur les données qu’on lui passe).

⚠️ Piège : ne stockez pas d’état par requête dans une propriété de service (ex. « l’utilisateur courant » dans une propriété que vous modifiez). Comme le service est partagé, cet état « fuit » entre usages. Les services doivent être sans état : ils reçoivent les données en argument de méthode, pas en propriété mutable. (En mode worker FrankenPHP, cette discipline est encore plus critique.)

7. Diagnostiquer l’injection

# Quels types puis-je injecter (autowiring) ? php bin/console debug:autowiring php bin/console debug:autowiring mailer # filtrer # Tous les services du conteneur (et leur classe) php bin/console debug:container php bin/console debug:container BookingCreator # détail d'un service

debug:autowiring liste tous les types injectables (interfaces et classes) et l’implémentation qui sera fournie — indispensable quand une injection « ne trouve pas » le bon service.

💡 Pour un dev Next.js : debug:autowiring répond à « quel provider Nest injectera-t-il pour ce type ? », mais sans que vous ayez à déclarer un module. C’est l’un des grands conforts de Symfony : la découverte + le câblage sont automatiques, et outillés.

✏️ Exercices

Exercice 1. Créez un service PriceCalculator qui dépend d’un TaxRateProvider (interface) et expose total(int $amountCents): int. Montrez comment il serait injecté dans un contrôleur.

✅ Solution

// src/Service/PriceCalculator.php final class PriceCalculator { public function __construct(private readonly TaxRateProvider $taxRates) {} public function total(int $amountCents): int { return (int) round($amountCents * (1 + $this->taxRates->current())); } } // dans un contrôleur : autowiring, aucune config public function quote(int $amount, PriceCalculator $calc): JsonResponse { return $this->json(['total' => $calc->total($amount)]); }

PriceCalculator est auto-enregistré (dans src/), TaxRateProvider est autowiré s’il n’a qu’une implémentation.

Exercice 2. Pourquoi injecter MailerInterface plutôt que SmtpMailer ?

✅ Solution

Pour découpler votre code de l’implémentation : vous dépendez du contrat (envoyer un e-mail), pas du comment (SMTP). On peut ainsi changer l’implémentation (SMTP → API tierce → faux mailer en test) sans modifier votre classe, et tester en injectant un double. C’est la clé de la testabilité et du remplacement.

Exercice 3. Un collègue stocke $this->currentUser dans une propriété de service qu’il modifie à chaque appel. Quel bug guette, et quelle est la bonne pratique ?

✅ Solution

Le service est un singleton partagé : stocker un état par requête/appel dans une propriété fait fuiter cet état entre les usages (et c’est catastrophique en mode worker où l’instance survit entre requêtes). Bonne pratique : les services sont sans état — passez User $user en argument de méthode plutôt que de le stocker en propriété. L’état vit dans les paramètres, pas dans le service.

🧠 Quiz de révision

1. Qu’est-ce qu’un service et où vit-il ?

Un objet qui fait un travail (mailer, créateur de réservation…), enregistré dans le conteneur de services qui le construit et le fournit à la demande.

2. Qu’est-ce que l’autowiring ?

Le mécanisme qui lit les types des arguments du constructeur et injecte automatiquement le bon service, sans configuration manuelle.

3. Pourquoi injecter des interfaces plutôt que des classes concrètes ?

Pour le découplage et la testabilité : on dépend du contrat, on peut changer l’implémentation ou injecter un double sans modifier le code.

4. Un service est-il recréé à chaque injection ?

Non : c’est un singleton — une seule instance partagée (par requête). D’où l’importance de garder les services sans état.

5. Quelle commande liste les types injectables ?

php bin/console debug:autowiring (et debug:container pour tous les services).


Prochain chapitre : 5.2 — Config des services, paramètres, environnementsservices.yaml, paramètres, #[Autowire], et la config par environnement.