Il y a des notions qui changent vraiment la manière d’écrire du code. L’injection de dépendances en fait partie.
Quand on débute en PHP, on écrit souvent des classes qui fabriquent elles-mêmes tout ce dont elles ont besoin. Sur le moment, cela paraît simple. On crée un objet, on appelle une méthode, et on avance. Le problème, c’est qu’au bout d’un moment ce style de code devient rigide. Il est plus difficile à tester, plus difficile à faire évoluer et plus difficile à relire quand l’application grandit.
L’idée de l’injection de dépendances est simple : une classe ne devrait pas être responsable de construire elle-même tous ses outils. Elle devrait surtout exprimer clairement ce dont elle a besoin pour faire son travail.
Cette idée n’est pas propre à Symfony. Martin Fowler décrit l’injection de dépendances comme une manière de composer les objets sans que chaque classe aille elle-même récupérer ou fabriquer ses collaborateurs. Symfony, de son côté, s’appuie sur un service container et sur l’autowiring pour câbler les services de manière centralisée et prévisible. En PHP, le constructeur reste naturellement le point classique pour initialiser les dépendances d’un objet. Source Martin Fowler · Source Symfony Service Container · Source Symfony Autowiring · Source PHP Constructors
Pourquoi ce sujet est important
Une classe métier sérieuse ne devrait pas passer son temps à instancier des clients HTTP, des repositories, des loggers, des notifiers ou des services externes. Son rôle, c’est de porter une logique métier claire.
Quand une classe construit elle-même ses dépendances, elle se retrouve vite couplée à des choix techniques précis. Si demain tu veux changer d’implémentation, mocker un composant dans un test, ou réutiliser la même logique dans un autre contexte, cela devient plus lourd que nécessaire.
À l’inverse, quand les dépendances sont injectées proprement, le code devient plus lisible, plus remplaçable et plus testable. Symfony documente explicitement que rendre les dépendances explicites améliore la réutilisabilité, le découplage et les tests. Source Symfony Types of Injection
Le problème classique sans injection de dépendances
Prenons un exemple très simple. On veut envoyer un email de bienvenue lors de l’inscription d’un utilisateur.
<?php
class Mailer
{
public function sendWelcomeEmail(string $email): void
{
// Envoi réel de l'email
}
}
class UserRegistrationService
{
public function register(string $email): void
{
// logique d'inscription
$mailer = new Mailer();
$mailer->sendWelcomeEmail($email);
}
}
Ce code fonctionne, mais il mélange deux responsabilités : la logique métier d’inscription et la construction technique de la dépendance.
Le service est maintenant fortement couplé à la classe Mailer. Tu ne peux pas facilement remplacer l’envoi réel par une fausse implémentation en test. Tu ne peux pas non plus changer facilement de système d’envoi sans modifier le code métier.
La première amélioration en PHP pur
La première étape saine consiste à injecter la dépendance par le constructeur.
<?php
class Mailer
{
public function sendWelcomeEmail(string $email): void
{
// Envoi réel de l'email
}
}
class UserRegistrationService
{
public function __construct(
private Mailer $mailer
) {
}
public function register(string $email): void
{
// logique d'inscription
$this->mailer->sendWelcomeEmail($email);
}
}
Ici, la classe ne crée plus le mailer elle-même. Elle annonce simplement qu’elle en a besoin. C’est beaucoup plus propre. PHP prévoit justement le constructeur pour initialiser un objet lorsqu’il est créé. Source PHP Constructors
Pourquoi c’est déjà meilleur
- la classe exprime clairement sa dépendance ;
- la logique métier reste concentrée sur son vrai rôle ;
- on peut remplacer la dépendance sans toucher au service ;
- les tests deviennent plus simples à écrire.
Cas pratique n°1 : injecter une interface plutôt qu’une classe concrète
Dans un vrai projet, dépendre directement d’une implémentation concrète est rarement l’idéal. Le plus souvent, on préfère dépendre d’un contrat, donc d’une interface.
<?php
interface MailerInterface
{
public function sendWelcomeEmail(string $email): void;
}
class SmtpMailer implements MailerInterface
{
public function sendWelcomeEmail(string $email): void
{
// envoi via SMTP
}
}
class FakeMailer implements MailerInterface
{
public array $sentEmails = [];
public function sendWelcomeEmail(string $email): void
{
$this->sentEmails[] = $email;
}
}
class UserRegistrationService
{
public function __construct(
private MailerInterface $mailer
) {
}
public function register(string $email): void
{
// logique d'inscription
$this->mailer->sendWelcomeEmail($email);
}
}
Maintenant, le service métier dépend d’une capacité, pas d’un outil précis. En production, tu peux injecter SmtpMailer. En test, tu peux injecter FakeMailer. Le code métier ne change pas.
Cas pratique n°2 : un vrai service métier
Prenons quelque chose de plus réaliste. On veut créer une commande, la persister, puis envoyer une notification.
<?php
interface OrderRepositoryInterface
{
public function save(array $orderData): void;
}
interface NotifierInterface
{
public function notifyNewOrder(string $email): void;
}
class CreateOrderService
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private NotifierInterface $notifier
) {
}
public function create(array $orderData): void
{
$this->orderRepository->save($orderData);
$this->notifier->notifyNewOrder($orderData['email']);
}
}
C’est là qu’on commence à voir l’intérêt concret de la DI. Le service ne sait pas si la persistance passe par Doctrine, une API ou un autre mécanisme. Il ne sait pas non plus si la notification part par email, Slack ou webhook. Il exécute seulement une logique métier.
Ce que l’injection de dépendances n’est pas
Un malentendu fréquent consiste à croire que DI veut dire “mettre absolument tout dans le constructeur”. Ce n’est pas ça.
L’idée n’est pas de rendre les classes artificiellement complexes. L’idée est d’éviter qu’une classe fabrique elle-même des dépendances importantes qui devraient être pilotées depuis l’extérieur. Symfony explique d’ailleurs les différents types d’injection et leurs usages. Source Symfony Types of Injection
Pourquoi Symfony rend ce sujet très agréable
En PHP pur, on peut très bien faire de l’injection de dépendances à la main. C’est d’ailleurs la meilleure manière de bien comprendre le mécanisme.
Mais dans une vraie application, construire soi-même tous les objets finit par devenir pénible. C’est là que Symfony apporte énormément de confort avec son service container. Le framework sait créer les services, résoudre leurs dépendances et injecter automatiquement les bons objets quand les type-hints sont explicites. Symfony appelle cela l’autowiring, et sa documentation insiste sur le fait que ce mécanisme est conçu pour rester prévisible : si le framework ne peut pas savoir clairement quoi injecter, il lève une erreur exploitable. Source Symfony Service Container · Source Symfony Autowiring
Premier exemple simple en Symfony
Imaginons un service de log métier, puis un service qui l’utilise.
Le logger métier
<?php
namespace App\Service;
class AuditLogger
{
public function log(string $message): void
{
// Ici on pourrait écrire dans un fichier,
// en base, ou déléguer à un vrai logger
}
}
Le service qui dépend de ce logger
<?php
namespace App\Service;
class UserCreator
{
public function __construct(
private AuditLogger $auditLogger
) {
}
public function create(string $email): void
{
// logique de création utilisateur
$this->auditLogger->log('Utilisateur créé : ' . $email);
}
}
Dans une application Symfony classique, si les services du dossier src/ sont bien auto-enregistrés, Symfony peut injecter AuditLogger automatiquement dans UserCreator grâce au type-hint du constructeur. C’est exactement le cas d’usage central de l’autowiring. Source Symfony Autowiring
Injection dans un contrôleur Symfony
On peut aussi injecter des services directement dans une action de contrôleur.
<?php
namespace App\Controller;
use App\Service\UserCreator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class UserController extends AbstractController
{
#[Route('/users/create-demo', name: 'users_create_demo')]
public function createDemo(UserCreator $userCreator): Response
{
$userCreator->create('demo@example.com');
return new Response('Utilisateur créé');
}
}
Cette approche reste cohérente avec les bonnes pratiques Symfony, qui recommandent de privilégier l’injection de dépendances plutôt que d’aller chercher les services manuellement dans le conteneur. Source Symfony Best Practices
Cas pratique Symfony réaliste : repository + HTTP client + logger
Prenons maintenant un cas plus proche d’un vrai projet. On veut synchroniser un client depuis une API externe, enregistrer les données, puis tracer l’opération.
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class CustomerSyncService
{
public function __construct(
private HttpClientInterface $httpClient,
private CustomerRepository $customerRepository,
private LoggerInterface $logger
) {
}
public function sync(int $customerId): void
{
$response = $this->httpClient->request(
'GET',
'https://api.example.com/customers/' . $customerId
);
$data = $response->toArray();
$this->customerRepository->saveFromApiData($data);
$this->logger->info('Client synchronisé', [
'customerId' => $customerId,
]);
}
}
Ce service montre très bien l’intérêt de la DI dans un contexte métier réel. La classe ne crée ni le client HTTP, ni le repository, ni le logger. Elle se concentre sur la synchronisation.
Quand plusieurs implémentations existent
Un cas classique arrive lorsqu’une interface a plusieurs implémentations. Par exemple, un système de notification par email et un autre par Slack.
<?php
namespace App\Contract;
interface NotifierInterface
{
public function send(string $message): void;
}
<?php
namespace App\Service;
use App\Contract\NotifierInterface;
class EmailNotifier implements NotifierInterface
{
public function send(string $message): void
{
// envoi email
}
}
<?php
namespace App\Service;
use App\Contract\NotifierInterface;
class SlackNotifier implements NotifierInterface
{
public function send(string $message): void
{
// envoi Slack
}
}
Dans ce cas, l’autowiring seul ne peut plus deviner automatiquement quelle implémentation utiliser. Symfony te demande alors de lever l’ambiguïté, ce qui est cohérent avec son principe d’autowiring prévisible. Source Symfony Autowiring
Par exemple dans services.yaml :
services:
App\Contract\NotifierInterface: '@App\Service\EmailNotifier'
Et voici le service qui consomme cette interface :
<?php
namespace App\Service;
use App\Contract\NotifierInterface;
class ReportSender
{
public function __construct(
private NotifierInterface $notifier
) {
}
public function sendDailyReport(): void
{
$this->notifier->send('Rapport quotidien généré');
}
}
Et l’injection par setter ?
Elle existe, mais elle doit rester plus rare. Symfony explique que l’injection par setter peut être utile surtout pour une dépendance optionnelle, tandis que l’injection par constructeur reste généralement le choix naturel pour une dépendance obligatoire. Source Symfony Setter Injection
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Service\Attribute\Required;
class ImportService
{
private ?LoggerInterface $logger = null;
#[Required]
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
public function run(): void
{
if ($this->logger) {
$this->logger->info('Import démarré');
}
// logique d'import
}
}
Si ton service ne peut pas fonctionner sans la dépendance, mets-la dans le constructeur. C’est plus clair, plus honnête, et plus simple à comprendre.
L’erreur fréquente : le Service Locator déguisé
Un piège classique consiste à injecter le conteneur lui-même, puis à aller chercher les services dedans au moment où on en a besoin.
<?php
class BadService
{
public function __construct(
private $container
) {
}
public function run(): void
{
$mailer = $this->container->get('mailer');
$mailer->sendWelcomeEmail('test@example.com');
}
}
Le problème ici, c’est que la vraie dépendance est cachée. On ne sait pas, en lisant la signature de la classe, de quoi elle a réellement besoin. Martin Fowler compare justement l’injection de dépendances à l’approche Service Locator et montre bien la différence conceptuelle entre les deux. Symfony recommande lui aussi de privilégier l’injection explicite. Source Martin Fowler · Source Symfony Best Practices
Pourquoi la DI change tout dans les tests
C’est souvent à ce moment-là que la valeur de la DI devient évidente. Une classe qui construit elle-même ses dépendances est difficile à isoler. Une classe qui les reçoit peut être testée avec des doubles très simples.
<?php
$fakeMailer = new FakeMailer();
$service = new UserRegistrationService($fakeMailer);
$service->register('john@example.com');
assert(in_array('john@example.com', $fakeMailer->sentEmails, true));
Le test reste lisible et focalisé sur le métier. On ne teste pas tout l’écosystème technique autour.
Comment raisonner sainement dans un projet réel
Une règle simple aide beaucoup : si une classe crée elle-même des objets importants comme un repository, un client HTTP, un logger, un notifier, un accès API ou un service de fichier, il y a souvent une opportunité de mieux découpler le code.
À l’inverse, tout n’a pas vocation à devenir un service. Une petite valeur de travail locale ou un objet éphémère strictement interne à une méthode n’ont pas forcément besoin d’être injectés.
Les règles simples que j’applique
- une dépendance obligatoire va dans le constructeur ;
- une dépendance optionnelle peut parfois passer par un setter ;
- je préfère dépendre d’une interface quand cela apporte une vraie souplesse ;
- je n’utilise pas le container comme une boîte magique dans mes services ;
- je garde les services centrés sur une responsabilité claire ;
- si une classe a beaucoup trop de dépendances, c’est souvent qu’elle fait trop de choses.
Conclusion
L’injection de dépendances n’est pas un gadget de framework. C’est une manière plus propre d’écrire du code orienté objet.
En PHP pur, elle aide déjà à mieux découpler la logique métier de la technique. En Symfony, elle devient très agréable parce que le service container et l’autowiring prennent en charge le câblage tout en gardant un comportement explicite et prévisible. Symfony documente clairement cette philosophie, ainsi que les différents types d’injection et leurs usages. Source Symfony Service Container · Source Symfony Autowiring · Source Symfony Types of Injection
S’il fallait résumer l’idée en une phrase, je dirais ceci : une bonne classe n’est pas une classe qui sait tout fabriquer. C’est une classe qui sait clairement dire de quoi elle a besoin pour faire correctement son travail.