Create a Custom Builder – Eine GotenbergBundle Story
In einem vorherigen Artikel haben wir uns angeschaut, wie du mit nur wenigen Zeilen Code dein erstes PDF generierst. Dabei kamen Gotenberg und das GotenbergBundle zum Einsatz – ein Symfony Bundle, das die HTTP API von Gotenberg kapselt, um HTML- oder Office-Dateien in PDFs umzuwandeln oder Screenshots zu erstellen. Das war ein super Anfang. Aber was passiert, wenn deine Applikation viele verschiedene PDFs erzeugen muss, jedes mit eigenem Layout, Style und Daten?
Vorheriger Artikel >> Hier finden
Schnell landest du bei duplizierter Konfiguration, überladenen Services und einer schwer wartbaren Conditional Logic. Dieser Artikel setzt genau dort an. Wir bauen einen custom Builder – eine dedizierte Klasse, die die gesamte Konfiguration und Logik für einen speziellen PDF-Typ kapselt. Am Ende wird dein Controller auf einen einzigen, aussagekräftigen Aufruf reduziert, und jeder PDF-Typ lebt in seiner eigenen, sauberen und testbaren Klasse.
Was ist der Zweck eines Custom Builders?
Ganz einfach: Dir das Leben leichter zu machen, wenn du mehrere PDFs generierst.
Wenn du in deiner Anwendung verschiedene PDFs erstellst, kann die Konfiguration oft nicht geteilt werden – meistens wegen unterschiedlicher Styles.
Hier kommt das Builder Pattern ins Spiel. Es ist ein Design Pattern, das dir hilft, komplexe Objekte Schritt für Schritt aufzubauen und dabei die Konstruktion von der finalen Repräsentation trennt. In unserem Kontext erlaubt es dir, die gesamte Konfiguration und Logik für einen spezifischen PDF-Typ in eine eigene Builder-Klasse auszulagern.
Durch einen custom Builder vermeidest du unübersichtliche Logik in deinen Services oder Code-Duplizierung. Stattdessen kümmert sich jeder Builder sauber um seine eigene Konfiguration, was deine Codebase wartbarer und erweiterbar macht.
Schritt 1: Dependencies aktualisieren
TL;DR: Siehe diesen Commit
Zuerst musst du deine Dependencies aktualisieren, um die neueste Version des GotenbergBundles zu erhalten – endlich die v1.2, die kein Experimental-Status mehr hat. Yeah! 🍾
composer require sensiolabs/gotenberg-bundle:1.2.*Schritt 2: Eine custom Builder Klasse erstellen
TL;DR: Siehe diesen Commit
Erstelle eine custom Builder Klasse, die AbstractBuilder erweitert und das BuilderAssetInterface implementiert.
<?php
namespace App\Pdf;
use Sensiolabs\GotenbergBundle\Builder\AbstractBuilder;
use Sensiolabs\GotenbergBundle\Builder\BuilderAssetInterface;
final class InvoicePdfBuilder extends AbstractBuilder
implements BuilderAssetInterface
{
protected function getEndpoint(): string
{
// TODO: Implement getEndpoint() method.
}
public function addAsset(string $path): static
{
// TODO: Implement addAsset() method.
}
}Alle nativen Builder erweitern AbstractBuilder, welcher die generate-Methode definiert. Er speichert auch alle benötigten Konfigurationen wie Margins, Width etc. und bereitet das Payload vor, bevor es an die Gotenberg API gesendet wird.
Da wir Assets in das Twig-Template einfügen, müssen wir das BuilderAssetInterface implementieren.
Füge nun das Attribut #[WithBuilderConfiguration('pdf', 'invoice')] oben an der Klasse hinzu.
<?php
namespace App\Pdf;
use Sensiolabs\GotenbergBundle\Builder\AbstractBuilder;
use Sensiolabs\GotenbergBundle\Builder\Attributes\WithBuilderConfiguration;
use Sensiolabs\GotenbergBundle\Builder\BuilderAssetInterface;
#[WithBuilderConfiguration('pdf', 'invoice')]
final class InvoicePdfBuilder extends AbstractBuilder implements BuilderAssetInterface
{
// rest of the code
}Dieses Attribut hilft dir dabei, eine semantische Konfiguration für diesen Builder zu erhalten. Das erste Argument ordnet den neuen Builder-Typ der Sektion 'pdf' oder 'screenshot' zu. Das zweite Argument ist der Name deiner Wahl.
Lass uns die Methoden implementieren. Für die getEndpoint-Methode nutzen wir die bestehende Konstante HtmlPdfBuilder::ENDPOINT, da der API-Endpoint derselbe ist.
Wenn du PDFs generieren willst, stehen folgende Traits zur Verfügung:
AssetTraitMethoden zum Hinzufügen von Assets.ContentTraitMethoden für verschiedene Content-Parts (Header, Footer, Content).CookieTraitSetzen und Weiterleiten von Cookies.CustomHttpHeadersTraitEigene HTTP-Header hinzufügen.EmulatedMediaTypeTraitEmulation von Screen oder Print.FailOnTraitVerhalten bei ungültigen Status-Codes.PdfPagePropertiesTraitPDF-Rendering anpassen (Layout).PerformanceModeTraitNicht darauf warten, dass das Chromium-Netzwerk idle ist.WaitBeforeRenderingTraitDelay vor der Konvertierung hinzufügen.DownloadFromTraitExterne Ressourcen einbinden.MetadataTraitMetadaten hinzufügen.PdfFormatTraitPDF-Formate.SplitTraitPDFs aufteilen.WebhookTraitWebhooks nutzen.
Alle diese sind im ChromiumPdfTrait kombiniert. Falls du Builder für Office oder Screenshots bauen willst, findest du die passenden Traits im Quellcode des Bundles auf GitHub.
Wir fügen das AssetTrait hinzu, das die addAsset-Methode für uns implementiert. Diese Methode speichert Assets aus Twig-Templates oder solche, die "on the fly" hinzugefügt werden.
Das ContentTrait ermöglicht die Nutzung von Methoden wie header und content im Controller oder Footer, wie sie in der sensiolabs_gotenberg.yaml konfiguriert sind.
Das PdfPagePropertiesTrait ist für Margins, Landscape-Modus, Papierformat etc. zuständig
<?php
namespace App\Pdf;
use Sensiolabs\GotenbergBundle\Builder\AbstractBuilder;
use Sensiolabs\GotenbergBundle\Builder\Attributes\WithBuilderConfiguration;
use Sensiolabs\GotenbergBundle\Builder\Behaviors\Chromium\AssetTrait;
use Sensiolabs\GotenbergBundle\Builder\Behaviors\Chromium\ContentTrait;
use Sensiolabs\GotenbergBundle\Builder\Behaviors\Chromium\PdfPagePropertiesTrait;
use Sensiolabs\GotenbergBundle\Builder\BuilderAssetInterface;
use Sensiolabs\GotenbergBundle\Builder\Pdf\HtmlPdfBuilder;
#[WithBuilderConfiguration('pdf', 'invoice')]
final class InvoicePdfBuilder extends AbstractBuilder implements BuilderAssetInterface
{
use AssetTrait;
use ContentTrait;
use PdfPagePropertiesTrait;
protected function getEndpoint(): string
{
return HtmlPdfBuilder::ENDPOINT;
}
}Schritt 3: Konfigurationsdatei aktualisieren
TL;DR: Siehe diesen Commit
Du musst nur den Namen des Builders anpassen. Nutze den Namen, den du im WithBuilderConfiguration-Attribut vergeben hast.
sensiolabs_gotenberg:
http_client: 'gotenberg.client'
default_options:
pdf:
- html:
+ invoice:
footer:
template: 'footer.html.twig'
paper_width: '21cm'
paper_height: '29.7cm'
margin_top: '6cm'
margin_bottom: '2cm'
landscape: trueSchritt 4: Controller aktualisieren
TL;DR: Siehe diesen Commit
Genau wie bei der Konfiguration müssen nur 2 Zeilen angepasst werden.
#[Route('/pdf', 'pdf')]
public function pdf(GotenbergPdfInterface $gotenbergPdf): Response
{
$invoiceData = $this->invoiceData();
- return $gotenbergPdf
- ->html()
+ return $gotenbergPdf->get(InvoicePdfBuilder::class)
->header('header.html.twig', [
'invoice' => $invoiceData['invoice'],
'client' => $invoiceData['client'],
])
->content('content.html.twig', [
'purchases' => $invoiceData['purchases'],
'invoice' => $invoiceData['invoice'],
])
->generate()
->stream()
;
}Schritt 5: Bringen wir es zum Laufen 💪
TL;DR: Siehe diesen Commit
Wenn du eine Version kleiner als v1.2 verwendest, musst du den Configurator manuell hinzufügen:
# services.yaml
services:
App\Pdf\InvoicePdfBuilder:
configurator: '@sensiolabs_gotenberg.builder_configurator'Seit Version v1.0.1 musst du das nicht mehr selbst machen. Registriere ihn einfach in der build-Methode deiner Kernel-Klasse (siehe bei Bedarf die Dokumentation zum Symfony-Kernel).
Wir holen uns die sensiolabs_gotenberg Extension und rufen registerBuilder mit dem FQCN des custom Builders auf. Et voilà!
class Kernel extends BaseKernel
{
use MicroKernelTrait;
protected function build(ContainerBuilder $container): void
{
/** @var SensiolabsGotenbergExtension $extension */
$extension = $container->getExtension('sensiolabs_gotenberg');
$extension->registerBuilder(InvoicePdfBuilder::class);
}
}Und alles funktioniert wie am Schnürchen.
Schritt 6: Warte... wir haben den eigentlichen Punkt vergessen! 🤦
TL;DR: Siehe diesen Commit
Huch, wir waren so auf den neuen Builder fokussiert, dass wir den eigentlichen Zweck vergessen haben: die Logik zu kapseln!
Aktuell weiß der Controller noch zu viel – er holt die Rechnungsdaten, übergibt sie dem Header, dem Content... Genau diese Verantwortung sollte im InvoicePdfBuilder liegen.
Verschieben wir das alles.
#[WithBuilderConfiguration('pdf', 'invoice')]
final class InvoicePdfBuilder extends AbstractBuilder implements BuilderAssetInterface
{
use AssetTrait;
use ContentTrait;
use PdfPagePropertiesTrait;
public function invoice(): self
{
$invoiceData = $this->invoiceData();
$this->header('header.html.twig', [
'invoice' => $invoiceData['invoice'],
'client' => $invoiceData['client'],
]);
$this->content('content.html.twig', [
'purchases' => $invoiceData['purchases'],
'invoice' => $invoiceData['invoice'],
]);
return $this;
}
protected function getEndpoint(): string
{
return HtmlPdfBuilder::ENDPOINT;
}
private function invoiceData(): array
{
$factory = Factory::create();
$allPurchases = [];
for ($i = 0; $i < 20; $i++) {
$allPurchases[] = [
'orderId' => $factory->unixTime(),
'period' => $factory->dateTimeBetween('- 1 week')->format('Y-m-d') . ' - ' . $factory->dateTime('now')->format('Y-m-d'),
'description' => $factory->sentence(),
'price' => $factory->randomFloat(2, 1),
'quantity' => $factory->randomDigitNotZero(),
'total' => $factory->randomFloat(2, 1),
];
}
return [
'invoice' => [
'id' => $factory->unixTime(),
'date' => $factory->dateTime()->format('Y-m-d'),
'due_date' => $factory->dateTime('+1 week')->format('Y-m-d'),
'sub_total' => $factory->randomFloat(2, 1),
'total' => $factory->randomFloat(2, 1),
],
'client' => [
'phone_number' => $factory->e164PhoneNumber(),
'name' => $factory->company(),
'address' => $factory->address(),
'city' => $factory->city(),
],
'purchases' => $allPurchases
];
}
}Jetzt wird der Controller extrem simpel, genau wie es Symfony Best Practices vorsehen.
#[Route('/pdf', 'pdf')]
public function pdf(GotenbergPdfInterface $gotenbergPdf): Response
{
- $invoiceData = $this->invoiceData();
-
- return $gotenbergPdf->get(InvoicePdfBuilder::class)
- ->header('header.html.twig', [
- 'invoice' => $invoiceData['invoice'],
- 'client' => $invoiceData['client'],
- ])
- ->content('content.html.twig', [
- 'purchases' => $invoiceData['purchases'],
- 'invoice' => $invoiceData['invoice'],
- ])
- ->generate()
- ->stream()
- ;
+ return $gotenbergPdf->get(InvoicePdfBuilder::class)
+ ->invoice()
+ ->generate()
+ ->stream()
+ ;
}Der Controller weiß nichts mehr über Templates oder Datenstrukturen. Er sagt nur noch: "Gib mir das Rechnungs-PDF" und der Builder erledigt den Rest. Das ist das Ziel! 🎯
Fazit
Wenn du nun php bin/console debug:config sensiolabs_gotenberg ausführst, siehst du die Konfiguration deines custom Builders sauber integriert.
Aber was noch wichtiger ist:
Der Controller ist clean. Keine Ahnung mehr von Templates oder Rendering-Logik.
Die Logik ist gekapselt. Der
InvoicePdfBuilderbesitzt alles, was mit Rechnungs-PDFs zu tun hat. Ändert sich das Layout morgen, weißt du genau, wo du ansetzen musst.Die Konfiguration ist semantisch. Dank
#[WithBuilderConfiguration('pdf', 'invoice')]list sich deine Dateisensiolabs_gotenberg.yamlganz natürlich und du knnest Ränder, Fusszeile oder Papierformat für jeden Builder ohn Umwge konfigurieren.Es ist skalierbar. Benötigst du einen
ReportPdfBuilder? Befolge dazu dieselben Schritte. Jeder PDF-Typ verfügt über einen eigenen Builder und Konfigurationsblock und beeinträchtigt die anderein in keiner Weise.
Das ist das Builder Pattern in Bestform im Symfony-Kontext. Probier es in deinem nächsten Projekt aus! 🚀