Les Lazy Services de Symfony : Boostez votre DX en utilisant les Service Subscribers

Optimisez la performance de votre application Symfony et l'expérience développeur ! Apprenez à utiliser les Service Subscribers et les attributs de chargement différé des services afin de réduire l'instanciation rapide, de simplifier les dépendances et de créer un code modulaire et maintenable.
Dans Symfony, il est tout aussi important de structurer proprement vos services que d'optimiser leur performance. En effet, un code maintenable est plus évolutif à long terme que des micro-optimisations. Un service étroitement couplé avec des dépendances surchargées est plus difficile à tester, à étendre, voire à comprendre.
Dans cet article, nous allons voir l'utilité des Service Subscribers à travers un exemple que j'ai pu observer.
🚨 Le problème : Des constructeurs trop lourds à l'instanciation
Imaginez une classe de service comme celle-ci :
class ReportGenerator
{
public function __construct(
private LoggerInterface $logger,
private MailerInterface $mailer,
private GotenbergPdfInterface $gotenbergPdf,
private Environment $twig,
// more, and more sometimes...
) {}
}
Elle implique de nombreuses dépendances, mais seulement une ou deux peuvent être utilisées sur certaines requêtes.
Pire encore :
Symfony instancie rapidement tous ces services et toutes leurs propres dépendances, même s'ils ne sont jamais utilisés.
Cette classe est difficilement extensible, car chaque enfant doit redéfinir ou transmettre manuellement les arguments du constructor.
.
La solution : Utiliser les Service Subscribers
🔧 Étape 1: Mettre à jour le service pour charger en différé les services injectés
Mettez à jour votre service en intégrant ServiceSubscriberInterface
. La mise en place de cette interface requiert l'implémentation de la méthode publique et statique getSubscribedServices
. Cette méthode renvoie un tableau d'objets SubscribedServices ou un tableau ayant une clé de type string qui sert d'identifiant pour obtenir ce service, ainsi qu'une valeur avec une valeur avec le FQCN du service.
Vous aurez aussi besoin d'ajouter une méthode comme setContainer(Psr\Container\ContainerInterface $container)
avec l'attribut #[Required]
.L'attribut #[Required]
permet au container d'injection de dépendance de Symfony d'appeler automatiquement cette méthode pendant l'initialisation du service.
// src/Service/ReportGenerator.php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Sensiolabs\GotenbergBundle\GotenbergPdfInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;
use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Twig\Environment;
class ReportGenerator implements ServiceSubscriberInterface
{
private ContainerInterface $container;
#[Required]
public function setContainer(ContainerInterface $container): ?ContainerInterface
{
$previous = $this->container ?? null;
$this->container = $container;
return $previous;
}
public static function getSubscribedServices(): array
{
return [
'logger' => LoggerInterface::class,
'mailer' => MailerInterface::class,
'sensiolabs_gotenberg.pdf' => GotenbergPdfInterface::class,
'twig' => Environment::class,
];
}
protected function getLogger(): LoggerInterface
{
return $this->container->get('logger');
}
protected function getMailer(): MailerInterface
{
return $this->container->get('mailer');
}
protected function getPdfGenerator(): GotenbergPdfInterface
{
return $this->container->get('sensiolabs_gotenberg.pdf');
}
protected function getTwig(): Environment
{
return $this->container->get('twig');
}
/** rest of your code */
}
Il existe une solution alternative en ajoutant ServiceMethodsSubscriberTrait
. Cette solution fournit une implémentation pour ServiceSubscriberInterface
qui parcourt toutes les méthodes de votre classe qui sont signalées par l'attribut #[SubscribedService]
.L'avantage de cette méthode est de décrire les services requis par la classe en fonction du type de retour de chaque méthode.Elle vous permet également d'intégrer la méthode setContainer(Psr\Container\ContainerInterface $container)
.
// src/Service/ReportGenerator.php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Sensiolabs\GotenbergBundle\GotenbergPdfInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;
use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Twig\Environment;
class ReportGenerator implements ServiceSubscriberInterface
{
use ServiceMethodsSubscriberTrait;
#[SubscribedService('logger')]
protected function getLogger(): LoggerInterface
{
return $this->container->get('logger');
}
#[SubscribedService('mailer')]
protected function getMailer(): MailerInterface
{
return $this->container->get('mailer');
}
#[SubscribedService('sensiolabs_gotenberg.pdf')]
protected function getPdfGenerator(): GotenbergPdfInterface
{
return $this->container->get('sensiolabs_gotenberg.pdf');
}
#[SubscribedService('twig')]
protected function getTwig(): Environment
{
return $this->container->get('twig');
}
/** rest of your code */
}
🔁 Les services ne sont désormais récupérés que lorsqu'ils sont réellement utilisés, et ne sont jamais instanciés inutilement.
D'accord, c'est mieux pour la performance, mais que se passe-t-il si nous avons d'autres classes qui ont besoin de générer des pages de rapports HTML, des e-mails ou des PDF ?Nous pouvons résoudre tout ça grâce à une seule classe d'abstraction réutilisable.
🔧 Étape 2: Créer une classe d'abstraction de base
Mettez-y toute la logique du Service Subscriber.
// src/Service/AbstractReport.php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Sensiolabs\GotenbergBundle\GotenbergPdfInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;
use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Twig\Environment;
abstract class AbstractReport implements ServiceSubscriberInterface
{
use ServiceMethodsSubscriberTrait;
#[SubscribedService('logger')]
protected function getLogger(): LoggerInterface
{
return $this->container->get('logger');
}
#[SubscribedService('mailer')]
protected function getMailer(): MailerInterface
{
return $this->container->get('mailer');
}
#[SubscribedService('sensiolabs_gotenberg.pdf')]
protected function getPdfGenerator(): GotenbergPdfInterface
{
return $this->container->get('sensiolabs_gotenberg.pdf');
}
#[SubscribedService('twig')]
protected function getTwig(): Environment
{
return $this->container->get('twig');
}
}
👶 Étape 3: Étendre la classe d'abstraction de base
Mettons maintenant à jour le service de reporting qui étend cette classe de base.
Cela nous donnera un service plus lisible, dont les services sont récupérés en différé.
Et que se passe-t-il si vous avez besoin du service uniquement pour cette classe ? Vous avez juste besoin d'un constructeur pour ce service, sans avoir à vous soucier d'un constructeur parent.
// src/Service/ReportService.php
namespace App\Service;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class UserReport extends AbstractReport
{
public function __construct(private readonly CsvGenerator $csvGenerator)
{}
public function generateMail(string $recipientEmail): void
{
/* rest of your code */
$this->getMailer()->send(/* send email */);
$this->getLogger()->info('Email report sent to ' . $recipientEmail);
}
public function generatePdf(): StreamedResponse
{
$pdf = $this->getPdfGenerator();
$this->getLogger()->info('PDF report generated');
return $pdf->html()
->content('content.html.twig')
->generate()
->stream()
;
}
public function generateView(): string
{
$view = $this->getTwig()->render('template.html.twig');
$this->getLogger()->info('Report page generated');
return $view;
}
public function generateCsv(): string
{
$csv = $this->csvGenerator->generate();
/** rest of your code */
}
}
Vous améliorez ainsi l'expérience développeur (DX)
✅ Pas de modèle de constructeur
🧠 Des accesseurs auto-documentés
🚀 Les nouveaux développeurs n'ont qu'à étendre la classe de base pour coder
⚡ Boost de la performance grâce au chargement différé
🧱 Une structure de service modulaire et respectant le principe DRY
📦 Utiliser les Lazy Service Subscribers dans un bundle Symfony (avec des dépendances optionnelles)
Supposons que votre service fasse désormais partie d'un bundle Symfony réutilisable.
Si le client souhaite uniquement envoyer des rapports par e-mail (sans générer de PDF ni afficher de template Twig), il ne faut pas le forcer à installer des dépendances inutiles comme Twig ou GotenbergBundle.
Avec ServiceSubscriberInterface
et l'attribut #[SubscribedService]
de Symfony, nous pouvons rendre ces dépendances différées et optionnelles — utilisées uniquement si elles sont présentes.
Voici comment faire :
// src/Service/AbstractReport.php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Sensiolabs\GotenbergBundle\GotenbergPdfInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;
use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Twig\Environment;
abstract class AbstractReport implements ServiceSubscriberInterface
{
use ServiceMethodsSubscriberTrait;
#[SubscribedService('logger')]
private function getLogger(): LoggerInterface
{
return $this->container->get('logger');
}
#[SubscribedService('mailer')]
private function getMailer(): MailerInterface
{
return $this->container->get('mailer');
}
#[SubscribedService('sensiolabs_gotenberg.pdf', nullable: true)]
private function getPdfGenerator(): GotenbergPdfInterface
{
if (!$this->container->has('sensiolabs_gotenberg.pdf') || !($pdf = $this->container->get('sensiolabs_gotenberg.pdf')) instanceof GotenbergPdfInterface) {
throw new \LogicException(\sprintf('Gotenberg is required to use "%s" method. Try to run "composer require sensiolabs/gotenberg-bundle".', __METHOD__));
}
return $pdf;
}
#[SubscribedService('twig', nullable: true)]
private function getTwig(): Environment
{
if (!$this->container->has('twig') || !($environment = $this->container->get('twig')) instanceof Environment) {
throw new \LogicException(\sprintf('Twig is required to use "%s" method. Try to run "composer require symfony/twig-bundle".', __METHOD__));
}
return $environment;
}
}
Grâce à ce modèle, vous pouvez créer des services flexibles et extensibles qui :
N'utilisent les dépendances que si elles sont installées
Échouent correctement grâce à des messages d'erreur utiles
Gardent votre bundle léger et modulaire
Nous préférons cette approche pour améliorer l'expérience développeur, la maintenabilité dans GotenbergBundle (un bundle pour générer des PDF -> voir l'article sur le sujet), et rendre votre bundle plus facile à utiliser à une plus grande échelle d'un projet Symfony.
🏁 Conclusion
En combinant ServiceSubscriberInterface avec le trait du service subscriber, vous obtenez le meilleur des deux mondes :Utilisez uniquement les dépendances si elles sont installées
🔁 Un chargement propre et différé des services
⚡ Pas d'overload du runtime
🧠 Une meilleur expérience développeur (DX) lors de la création des services
Adoptez ce modèle dans vos projets Symfony et vous livrerez plus rapidement avec moins de duplication et un code plus propre.
💡 Le saviez-vous ?
Symfony lui-même utilise ce modèle en interne — notamment dans son AbstractController.
Au lieu d'injecter chaque service dans chaque contrôleur, Symfony utilise ServiceSubscriberInterface
.Cette méthode permet un chargement différé et rend les contrôleurs enfants légers et faciles à étendre, comme nous l'avons fait avec notre AbstractReport.