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

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.