Our Summer Sales are now available. Get 25% off your Symfony and PHP training courses taking place between July 7 and August 29 Contact us and get a quote


Symfony Lazy Services with Style: Boost DX using Service Subscribers

· Steven Renaux · 5 minutes to read
Two images: on the left many cars stuck in a traffic jam with the sign "All directions" above, on the right a blue car moving forward alone on the highway with the sign "Service Subscriber" and a Symfony logo above

Boost your Symfony app's performance and developer experience! Learn how to use Service Subscribers and traits for lazy service loading to reduce eager instantiation, simplify dependencies, and create modular, maintainable code.

In Symfony, structuring your services cleanly is just as important as optimizing performance. Because maintainable code scales better in the long run than micro-optimizations. A tightly coupled service with bloated dependencies is harder to test, extend, or even understand.

In this article, we’re going to see how useful service subscribers are through a typical example I have experienced.

🚨 The Problem: Eager-Loaded Constructors

Imagine a typical service class:

class ReportGenerator
{
   public function __construct(
       private LoggerInterface $logger,
       private MailerInterface $mailer,
       private GotenbergPdfInterface $gotenbergPdf,
       private Environment $twig,
       // more, and more sometimes...
   ) {}
}

That’s a lot of dependencies — and maybe only one or two are actually used during a specific execution path.

Even worse:

  • Symfony eagerly instantiates all these services and all of their own dependencies too, even if they’re never used.

  • This class is hardly extensible because every child must manually redefine or forward constructor arguments.

The Solution: Let’s use Service Subscribers

🔧 Step 1: Update the service to lazily load injected services

Update your service to implement ServiceSubscriberInterface. This interface requires the public static method getSubscribedServices to be implemented. This method returns an array of SubscribedServices objects or an array with a key string as the id to get the service and a value with the FQCN of the service.

You also need to add a method like setContainer(Psr\Container\ContainerInterface $container) with a the #[Required] attribute.The #[Required] attribute tells the Symfony dependency injection container to automatically call this method during service initialization.

// 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 */
}

There is an alternative solution by adding ServiceMethodsSubscriberTrait, it provides an implementation for ServiceSubscriberInterface that looks through all methods in your class that are marked with the #[SubscribedService] attribute.It describes the services needed by the class based on each method’s return type.It also adds the method setContainer(Psr\Container\ContainerInterface $container) for you.

// 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 */
}

🔁 Services are now only fetched when actually used — and never eagerly instantiated.

Okay, it’s better about performance, but if we have other classes that need to generate report HTML pages, mail or PDF…We can solve all of that with one reusable abstract class.

🔧 Step 2: Create the Abstract Base Class

Put all the logic about Service Subscriber into it.

// 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');
   }
}

👶 Step 3: Let’s extend this Abstract Base Class

Now let’s update the reporting service that extends this base class.This will give us a more readable service, whose services are fetched lazily.

And if you need a service only for this class. You only need a constructor with this service, without dealing with a parent constructor.

// 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 */
   }
}

It Improves Developer Experience (DX)

  • ✅ No constructor boilerplate

  • 🧠 Self-documenting accessors

  • 🚀 New devs just extend and go

  • ⚡ Performance boost via lazy loading

  • 🧱 Modular and DRY service structure

📦 Using Lazy Service Subscribers in a Symfony Bundle (with Optional Dependencies)

Let’s say your service is now part of a reusable Symfony bundle.

If the consumer only wants to send reports via email (without generating PDFs or rendering Twig templates), we shouldn’t force them to install unnecessary dependencies like Twig or GotenbergBundle.

With ServiceSubscriberInterface and Symfony’s #[SubscribedService] attribute, we can make those dependencies lazy and optional — only used if present.

Here’s how:

// 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;
   }
}

With this pattern, you can build flexible and extendable services that:

  • Only use dependencies if they’re installed

  • Fail gracefully with helpful error messages

  • Keep your bundle lightweight and modular

We prefer this approach to improve developer experience, maintainability in GotenbergBundle (Bundle to generate PDF -> see the article about talks at SymfonyCon Vienna 2024), and make your bundle easier to adopt on a broader scale of Symfony project.

🏁 Conclusion

By combining ServiceSubscriberInterface with the service subscriber trait, you get the best of both worlds:Only use dependencies if they’re installed

  • 🔁 Clean and lazy service loading

  • ⚡ No runtime overhead

  • 🧠 Better developer experience (DX) when creating services

Adopt this pattern in your Symfony projects, and you’ll ship faster with less duplication, and cleaner code.

💡 Did you know?

Symfony itself uses this pattern internally — most notably in its AbstractController.

Instead of injecting every service into every controller, Symfony uses ServiceSubscriberInterface.This enables lazy loading and makes child controllers lightweight and easy to extend, just like we did with our AbstractReport.

Are you looking to take your DX to new heights with Service Subscribers?

Our team of experts is ready to help you improve your performance and developer experience. Tell us more about your development project.

Image