Brauchst du einen Experten, der dir bei deinem Symfony- oder PHP-Entwicklungsprojekt hilft? Fordere jetzt ein Angebot an


Symfony Lazy Services mit Stil – Steigere deine Entwicklererfahrung mit Service Subscribers

· Steven Renaux · 6 Minuten zum Lesen
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

Steigere die Performance und Developer Experience (DX) deiner Symfony-App! Erfahre, wie du Service Subscribers und Traits für das verzögerte Laden von Services verwendest, um die sofortige Instanziierung zu reduzieren, Abhängigkeiten zu vereinfachen und modularen, wartbaren Code zu schreiben.

In Symfony ist es genauso wichtig, deine Services sauber zu strukturieren, wie ihre Performance zu optimieren. Tatsächlich ist wartbarer Code langfristig besser erweiterbar als Mikro-Optimierungen. Ein Service, der stark gekoppelt ist und überladene Abhängigkeiten hat, ist schwieriger zu testen, zu erweitern und sogar zu verstehen.

In diesem Artikel werden wir die Nützlichkeit von Service Subscribers anhand eines charakteristischen Beispiels untersuchen, das ich beobachten konnte.

🚨 Die Herausforderung: Zu schnell überladene Constructors

Stell dir eine klassische Service-Klasse vor:

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

Sie beinhaltet zahlreiche Abhängigkeiten, aber nur ein oder zwei werden im spezifischen Ausführungspfad tatsächlich verwendet.

Schlimmer noch:

  • Symfony instanziiert sofort all diese Services und all ihre eigenen Abhängigkeiten, selbst wenn sie niemals verwendet werden.

  • Diese Klasse ist schwer erweiterbar, da jede Kind-Klasse die Constructor-Argumente manuell neu definieren oder übergeben muss.

Die Lösung: Service Subscribers verwenden

🔧 Schritt 1: Den Service aktualisieren, um injizierte Services verzögert zu laden

Aktualisiere deinen Service, indem du das ServiceSubscriberInterface implementierst. Die Implementierung erfordert die öffentliche statische Methode getSubscribedServices. Diese Methode gibt ein Array von SubscribedServices-Objekten oder ein Array mit einem String-Schlüssel, der als Bezeichner zum Abrufen dieses Services dient, sowie einem Wert mit dem FQCN des Services zurück.

Du musst auch eine Methode wie setContainer(Psr\Container\ContainerInterface $container) mit dem Attribut #[Required] hinzufügen. Das Attribut #[Required] ermöglicht es dem Symfony-Dependency-Injection-Container, diese Methode während der Service-Initialisierung automatisch aufzurufen.

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

Es gibt eine alternative Lösung, der ServiceMethodsSubscriberTrait . Diese Lösung bietet eine Implementierung für ServiceSubscriberInterface, die alle Methoden deiner Klasse durchläuft, die mit dem #[SubscribedService] Attribut gekennzeichnet sind. Der Vorteil dieser Methode ist, dass die von der Klasse benötigten Services basierend auf dem Rückgabetyp jeder Methode beschrieben werden. Sie ermöglicht es dir auch, die Methode setContainer(Psr\Container\ContainerInterface $container) zu integrieren.

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

🔁 Die Services werden nun nur noch abgerufen, wenn sie tatsächlich verwendet werden, und werden niemals zu früh instanziiert.

Okay, das ist besser für die Performance, aber was passiert, wenn wir andere Klassen haben, die HTML-Berichtsseiten, E-Mails oder PDFs generieren müssen? Wir können all das mit einer einzigen wiederverwendbaren Abstraktionsklasse lösen.

🔧 Schritt 2: Eine grundlegende Abstraktionsklasse erstellen

Füge dort die gesamte Logik für den Service Subscriber hinzu.

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

👶 Schritt 3: Die grundlegende Abstraktionsklasse erweitern

Aktualisieren wir nun den UserReport-Service, der diese Basisklasse nutzt.

Dies wird uns einen besser lesbaren Service liefern, dessen Services verzögert abgerufen werden.

Und was passiert, wenn du den Service nur für diese Klasse benötigst? Du brauchst nur einen Constructor für diesen Service, ohne dich um einen übergeordneten Constructor kümmern zu müssen.

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

So verbesserst so die Developer Experience (DX)

✅ Kein Constructor-Boilerplate

🧠 Selbst-dokumentierende Accessoren

🚀 Neue Entwickler müssen das Projekt nur erweitern

⚡ Performance-Boost durch verzögertes Laden

🧱 Eine modulare und DRY-Service-Struktur

📦 Lazy Service Subscribers in einem Symfony Bundle verwenden (mit optionalen Abhängigkeiten)

Nehmen wir an, dein Service ist nun Teil eines wiederverwendbaren Symfony Bundles.

Wenn der Kunde nur Berichte per E-Mail versenden möchte (ohne PDFs zu generieren oder Twig-Templates anzuzeigen), sollte er nicht gezwungen werden, unnötige Abhängigkeiten wie Twig oder GotenbergBundle zu installieren.

Mit dem ServiceSubscriberInterface und dem #[SubscribedService] Attribut von Symfony können wir diese Abhängigkeiten verzögert und optional machen – sie werden nur verwendet, wenn sie vorhanden sind:

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

Mit diesem Muster kannst du flexible und erweiterbare Services erstellen, die:

  • Abhängigkeiten nur verwenden, wenn sie installiert sind

  • Korrekte Fehlerbehandlung mit nützlichen Fehlermeldungen bieten

  • Dein Bundle leicht und modular halten

Wir bevorzugen diesen Ansatz, um die Entwicklererfahrung und Wartbarkeit im GotenbergBundle (ein Bundle zur PDF-Generierung -> siehe den Artikel über die Vorträge auf der SymfonyCon Vienna 2024) zu verbessern und dein Bundle in größerem Umfang eines Symfony-Projekts einfacher nutzbar zu machen.

🏁 Fazit

Durch die Kombination von ServiceSubscriberInterface mit dem ServiceSubscriberTrait erhältst du das Beste aus beiden Welten: Abhängigkeiten nur verwenden, wenn sie installiert sind.

🔁 Sauberes und verzögertes Laden von Services

⚡ Keine Überlastung der Laufzeit

🧠 Eine bessere Developer Experience (DX) beim Erstellen von Services

Übernehme dieses Pattern in deinen Symfony-Projekten und du wirst schneller liefern, mit weniger Duplikation und saubererem Code.

💡 Wusstest du das schon?

Symfony selbst verwendet dieses Muster intern – insbesondere in seinem AbstractController.

Anstatt jeden Service in jeden Controller zu injizieren, verwendet Symfony das ServiceSubscriberInterface. Diese Methode ermöglicht ein verzögertes Laden und macht die untergeordneten Controller leicht und einfach erweiterbar, genau wie wir es mit unserem AbstractReport gemacht haben.

Möchtest du deine Developer Experience auf ein neues Level heben?

Unser Expertenteam ist bereit, dir dabei zu helfen, deine Performance und Entwicklererfahrung zu verbessern. Erzähl uns mehr über dein Entwicklungsprojekt!

Image