Gotenberg Bundle: Die PDF-Lösung, die wirklich funktioniert
Oder: Wie ich aufhörte, mit wkhtmltopdf zu kämpfen und Docker lieben lernte
Drei Tage. So lange hab ich gebraucht, um wkhtmltopdf in Production zum Laufen zu kriegen. Binary-Dependencies, die auf meinem Rechner funktionieren, aber in Docker versagen. CSS Flexbox-Layouts, die im Browser perfekt aussehen, aber im PDF wie von 1995. Und von SSL-Zertifikat-Horror beim Rendern von HTTPS-Seiten fang ich gar nicht erst an.
Dann hab ich Gotenberg und das SensioLabs GotenbergBundle entdeckt. 15 Minuten später hatte ich PDFs, die exakt wie meine Webseiten aussahen, in jeder Umgebung funktionieren und 100+ Dokumentformate handhaben, von denen ich nicht mal wusste, dass ich sie brauche.
So überspringst du den ganzen Schmerz und gehst direkt zur Lösung, die funktioniert.
Das PDF-Problem, das wir alle kennen
Seien wir ehrlich - PDF-Generierung in PHP war schon immer beschissen. Kennste das?
Option 1: Native PHP-Bibliotheken wie TCPDF oder mPDF. Super, wenn du PDFs gern wie 1995 schreibst, mit manueller Positionierung und beten, dass deine Tabelle nicht über Seiten umbricht.
Option 2: wkhtmltopdf. Geniale Ergebnisse… wenn’s läuft. Aber versuch mal das zu deployen:
# Der Tanz, den wir alle kennen
apt-get update && apt-get install -y \
wkhtmltopdf \
xvfb \
libfontconfig \
fontconfig \
# ... 47 andere Dependencies
# Läuft immer noch nicht? Probier diese Magie:
xvfb-run -a --server-args="-screen 0, 1024x768x24" wkhtmltopdf ...
Option 3: Puppeteer/Browsershot. Perfekte Ausgabe, aber jetzt brauchst du Node.js, Chrome und genug RAM für ein kleines Rechenzentrum.
Option 4: Cloud-Services. Funktioniert super bis zur Rechnung oder wenn du sensible Dokumente offline verarbeiten musst.
Enter Gotenberg: Der Game Changer
Gotenberg ist eine Docker-basierte API, die Chromium, LibreOffice und PDF-Manipulation-Tools in einen einzigen RESTful Service packt. Stell dir ein Schweizer Taschenmesser für Dokumentkonvertierung vor, das auch wirklich in die Tasche passt.
Die Magie: Statt Dependencies auf deinem Server zu installieren, lässt du Gotenberg als Container laufen. Deine Symfony-App macht einfach HTTP-Requests zur Dokumentkonvertierung. Das war’s.
Der Clou: Battle-tested mit über 70 Millionen Docker Hub Downloads.
Quick Win: Von Null auf PDF in 5 Minuten
Lass uns beweisen, dass das funktioniert, bevor wir tief einsteigen. So kriegst du dein erstes PDF in unter 5 Minuten:
Schritt 1: Gotenberg starten
# Ein Command to rule them all
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
Schritt 2: Bundle installieren
composer require sensiolabs/gotenberg-bundle
Schritt 3: Konfigurieren (falls du nicht Flex verwendest)
# config/packages/sensiolabs_gotenberg.yaml
framework:
http_client:
scoped_clients:
gotenberg.client:
base_uri: "http://localhost:3000"
sensiolabs_gotenberg:
http_client: "gotenberg.client"
Schritt 4: Dein erstes PDF generieren
<?php
// src/Controller/PdfController.php
namespace App\Controller;
use Sensiolabs\GotenbergBundle\GotenbergPdfInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class PdfController extends AbstractController
{
#[Route('/pdf/demo', name: 'pdf_demo')]
public function demo(GotenbergPdfInterface $gotenberg): Response
{
return $gotenberg->url()
->url('https://symfony.com')
->generate()
->stream(); // PDF lädt sofort runter
}
}
/pdf/demo aufrufen und boom - du hast ein perfektes PDF der Symfony-Homepage.
Keine Binary-Dependencies, keine Konfigurationshölle, keine schlaflosen Nächte.
Die echte Welt: Production Invoice System bauen
Jetzt bauen wir was Echtes. So verwende ich Gotenberg für Rechnungsgenerierung in einer Symfony-App:
Der Controller
<?php
namespace App\Controller;
use App\Entity\Invoice;
use Sensiolabs\GotenbergBundle\GotenbergPdfInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class InvoiceController extends AbstractController
{
public function __construct(
private readonly GotenbergPdfInterface $gotenberg,
private readonly string $projectDir
) {}
#[Route('/invoice/{id}/pdf', name: 'invoice_pdf')]
public function generatePdf(Invoice $invoice): Response
{
try {
$pdf = $this->gotenberg->html()
->content('invoice/pdf.html.twig', [
'invoice' => $invoice,
'company' => $invoice->getCompany(),
'items' => $invoice->getItems()
])
// Production-Settings für konsistente Ausgabe
->paperSize('A4')
->margins(15, 15, 15, 15) // mm
->preferCssPageSize(false)
->printBackground(true)
// Langsam ladende Elemente handhaben
->waitDelay(1000) // ms
->waitForExpression('window.invoiceReady === true')
->generate();
return $pdf
->stream()
->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
sprintf('rechnung-%s.pdf', $invoice->getNumber())
);
} catch (\Exception $e) {
$this->logger->error('PDF-Generierung fehlgeschlagen', [
'invoice_id' => $invoice->getId(),
'error' => $e->getMessage()
]);
throw new \RuntimeException('PDF konnte nicht generiert werden', 0, $e);
}
}
}
Das Twig Template
{# templates/invoice/pdf.html.twig #}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>Rechnung {{ invoice.number }}</title>
<style>
@page {
size: A4;
margin: 0;
}
body {
font-family: 'Helvetica Neue', Arial, sans-serif;
font-size: 12px;
line-height: 1.4;
color: #333;
margin: 15mm;
}
.invoice-header {
display: flex;
justify-content: space-between;
margin-bottom: 30px;
border-bottom: 2px solid #007ACC;
padding-bottom: 20px;
}
.company-logo {
max-width: 200px;
max-height: 80px;
}
.invoice-details {
text-align: right;
}
.invoice-number {
font-size: 24px;
font-weight: bold;
color: #007ACC;
}
.items-table {
width: 100%;
border-collapse: collapse;
margin: 30px 0;
}
.items-table th,
.items-table td {
padding: 12px;
border-bottom: 1px solid #ddd;
text-align: left;
}
.items-table th {
background-color: #f8f9fa;
font-weight: bold;
}
.total-row {
font-weight: bold;
background-color: #007ACC;
color: white;
}
.page-footer {
position: fixed;
bottom: 15mm;
left: 15mm;
right: 15mm;
font-size: 10px;
color: #666;
text-align: center;
border-top: 1px solid #ddd;
padding-top: 10px;
}
</style>
</head>
<body>
<header class="invoice-header">
<div class="company-info">
{% if company.logo %}
<img src="{{ gotenberg_asset('images/' ~ company.logo) }}"
alt="{{ company.name }}" class="company-logo">
{% endif %}
<div>
<strong>{{ company.name }}</strong><br>
{{ company.address|nl2br }}
</div>
</div>
<div class="invoice-details">
<div class="invoice-number">
Rechnung #{{ invoice.number }}
</div>
<div>
<strong>Datum:</strong> {{ invoice.date|date('d.m.Y') }}<br>
<strong>Fällig:</strong> {{ invoice.dueDate|date('d.m.Y') }}<br>
<strong>Status:</strong> {{ invoice.status }}
</div>
</div>
</header>
<main>
<div class="billing-info">
<h3>Rechnungsempfänger:</h3>
<strong>{{ invoice.client.name }}</strong><br>
{{ invoice.client.address|nl2br }}
</div>
<table class="items-table">
<thead>
<tr>
<th>Beschreibung</th>
<th style="text-align: right;">Menge</th>
<th style="text-align: right;">Preis</th>
<th style="text-align: right;">Betrag</th>
</tr>
</thead>
<tbody>
{% for item in invoice.items %}
<tr>
<td>{{ item.description }}</td>
<td style="text-align: right;">{{ item.quantity }}</td>
<td style="text-align: right;">{{ item.rate|number_format(2, ',', '.') }} €</td>
<td style="text-align: right;">{{ item.total|number_format(2, ',', '.') }} €</td>
</tr>
{% endfor %}
<tr class="total-row">
<td colspan="3"><strong>Gesamtbetrag</strong></td>
<td style="text-align: right;">
<strong>{{ invoice.total|number_format(2, ',', '.') }} €</strong>
</td>
</tr>
</tbody>
</table>
</main>
<footer class="page-footer">
{{ company.name }} | {{ company.email }} | {{ company.phone }}
</footer>
<script>
// Signal an Gotenberg, dass das Rendering fertig ist
window.invoiceReady = true;
</script>
</body>
</html>
Die gotenberg_asset() Magie: Diese Twig-Funktion bindet automatisch lokale Assets (Bilder, CSS, Fonts) in den PDF-Request ein. Keine kaputten Bilder oder fehlenden Styles mehr.
Advanced Techniken, die dir den Kopf wegblasen
Multi-Document PDFs
Mehrere Dokumente mergen? Gotenberg schafft das:
<?php
public function generateCompanyReport(Company $company): Response
{
// Einzelne PDFs generieren
$coverPage = $this->gotenberg->html()
->content('report/cover.html.twig', ['company' => $company])
->generate();
$financialReport = $this->gotenberg->html()
->content('report/financial.html.twig', ['company' => $company])
->generate();
$appendix = $this->gotenberg->url()
->url($this->generateUrl('company_data', ['id' => $company->getId()]))
->generate();
// Alle zusammenmergen
$merged = $this->gotenberg->merge()
->files(
$coverPage->getContent(),
$financialReport->getContent(),
$appendix->getContent()
)
->generate();
return $merged->stream();
}
Office-Dokumentkonvertierung
Erinnerst du dich an die 100+ Formate? So einfach geht’s:
<?php
#[Route('/convert/office', name: 'office_convert')]
public function convertOfficeDocument(Request $request): Response
{
$uploadedFile = $request->files->get('document');
if (!$uploadedFile) {
throw new BadRequestHttpException('Keine Datei hochgeladen');
}
// Gotenberg handhabt Word, Excel, PowerPoint, LibreOffice...
// Sogar obskure Formate wie .pages und .numbers
$pdf = $this->gotenberg->office()
->files($uploadedFile->getPathname())
// Office-spezifische Optionen
->landscape(false)
->pageRange('1-10') // Nur erste 10 Seiten konvertieren
->nativePageRanges('1-3,7,9-10') // Komplexe Bereiche
->generate();
return $pdf
->save(sprintf('%s/converted/%s.pdf',
$this->projectDir,
$uploadedFile->getClientOriginalName()
))
->stream();
}
Async-Verarbeitung mit Webhooks
Für große Dokumente verwende Webhooks, um Timeouts zu vermeiden:
<?php
public function generateLargeReport(Report $report): Response
{
$webhookUrl = $this->generateUrl(
'report_webhook',
['token' => $report->getWebhookToken()],
UrlGeneratorInterface::ABSOLUTE_URL
);
$this->gotenberg->html()
->content('report/large.html.twig', ['report' => $report])
->webhook($webhookUrl)
->webhookMethod('POST')
->webhookErrorURL($webhookUrl . '?error=1')
->generate(); // Kehrt sofort zurück
return $this->json([
'status' => 'processing',
'report_id' => $report->getId(),
'check_url' => $this->generateUrl('report_status', ['id' => $report->getId()])
]);
}
#[Route('/webhooks/report/{token}', name: 'report_webhook', methods: ['POST'])]
public function reportWebhook(string $token, Request $request): Response
{
$report = $this->reportRepository->findByWebhookToken($token);
if ($request->query->get('error')) {
$report->setStatus('failed');
$this->logger->error('Report-Generierung fehlgeschlagen', [
'report_id' => $report->getId(),
'error' => $request->getContent()
]);
} else {
// PDF-Content speichern
$pdfContent = $request->getContent();
$this->s3->upload("reports/{$report->getId()}.pdf", $pdfContent);
$report->setStatus('completed');
}
$this->entityManager->flush();
return new Response('OK');
}
Die Alternativen (und warum ich Gotenberg gewählt hab)
Lass mich brutal ehrlich über deine Optionen sein:
TCPDF
✅ Pros:
- Keine Dependencies
- Handhabt komplexe Layouts gut
- Unicode-Support
❌ Cons:
- Fühlt sich an wie PDFs in Assembler programmieren
- Kein HTML/CSS-Support
- Lernkurve steiler als der Mount Everest
Fazit: Super, wenn du PDFs from scratch baust und Schmerz liebst
Dompdf
✅ Pros:
- Einfache HTML-zu-PDF-Konvertierung
- Leichtgewichtig
- Easy zu verstehen
❌ Cons:
- CSS-Support ist basically CSS 2.0
- Kein Flexbox, kein Grid, nix modernes
- Langsam bei komplexen Layouts
Fazit: Perfekt für einfache Belege und basic Reports
mPDF
✅ Pros:
- Besserer CSS-Support als Dompdf
- Gute Dokumentation
- Handhabt die meisten Standard-Use-Cases
❌ Cons:
- Immer noch keine modernen CSS-Features
- Performance wird träge bei großen Dokumenten
- Der Maintainer sagt auf der Homepage, es sei “dated”
Fazit: Der sichere Mittelweg, aber zeigt sein Alter
wkhtmltopdf
✅ Pros:
- Exzellenter CSS-Support
- Schnelles Rendering
- Battle-tested
❌ Cons:
- Binary Dependency Hell
- Kein Flexbox-Support (uses old QtWebKit)
- SSL-Zertifikat-Nightmares
- Deployment-Komplexität
Fazit: Wär perfekt, wenn’s nicht 2025 wäre
Browsershot/Puppeteer
✅ Pros:
- Perfekter moderner CSS-Support
- Chrome’s Rendering-Engine
- JavaScript-Support
❌ Cons:
- Braucht Node.js
- Hoher Memory-Verbrauch
- Größere Dateigrößen
- Komplexes Setup
Fazit: Exzellente Wahl, wenn du mit den Dependencies klarkommst
Gotenberg
✅ Pros:
- Alle Vorteile von Puppeteer + LibreOffice + PDF-Tools
- Container-basiert (keine Dependency Hell)
- RESTful API (sprachagnostisch)
- 100+ Dokumentformate
- Produktionsbereite Webhooks
- Aktive Entwicklung
❌ Cons:
- Braucht Docker
- Netzwerk-Dependency (gemildert durch lokales Laufen)
- Lernkurve für erweiterte Features
Fazit: Die pragmatische Wahl für moderne Anwendungen
Production-Überlegungen (Das Zeug, das um 3 Uhr morgens zählt)
Docker-Konfiguration für Production
# docker-compose.yml
version: "3.8"
services:
gotenberg:
image: gotenberg/gotenberg:8
restart: unless-stopped
command:
- "gotenberg"
# Security: Gefährliche Features in Production deaktivieren
- "--api-disable-health-check-logging"
# Performance: Für deine Last optimieren
- "--chromium-max-queue-size=50"
- "--libreoffice-max-queue-size=20"
# Memory: OOM kills verhindern
- "--chromium-restart-after=100"
- "--libreoffice-restart-after=50"
# Network: SSL-Probleme handhaben
- "--chromium-ignore-certificate-errors"
mem_limit: 2g
mem_reservation: 512m
networks:
- app-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
app-network:
driver: bridge
SSL-Zertifikat-Probleme handhaben
Wenn deine Symfony-App auf HTTPS mit self-signed Zertifikaten läuft:
# config/packages/sensiolabs_gotenberg.yaml
sensiolabs_gotenberg:
request_context:
# Gotenberg sagen, wie es deine App von Docker aus erreicht
base_uri: "http://host.docker.internal:8080"
default_options:
html:
# HTTPS-Probleme handhaben
wait_timeout: 30
wait_delay: 1000
Error-Handling-Strategie
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Sensiolabs\GotenbergBundle\Exception\GotenbergApiErrorException;
use Sensiolabs\GotenbergBundle\GotenbergPdfInterface;
class PdfGeneratorService
{
public function __construct(
private readonly GotenbergPdfInterface $gotenberg,
private readonly LoggerInterface $logger,
private readonly int $maxRetries = 3
) {}
public function generateWithRetry(
string $template,
array $context = [],
array $options = []
): PdfResponse {
$attempt = 0;
$lastException = null;
while ($attempt < $this->maxRetries) {
try {
$builder = $this->gotenberg->html()
->content($template, $context);
// Optionen anwenden
foreach ($options as $method => $value) {
if (method_exists($builder, $method)) {
$builder->$method($value);
}
}
return $builder->generate();
} catch (GotenbergApiErrorException $e) {
$lastException = $e;
$attempt++;
$this->logger->warning('PDF-Generierung fehlgeschlagen, retry', [
'attempt' => $attempt,
'template' => $template,
'error' => $e->getMessage(),
'gotenberg_trace' => $e->getGotenbergTrace()
]);
if ($attempt < $this->maxRetries) {
// Exponential backoff
sleep(pow(2, $attempt));
}
}
}
$this->logger->error('PDF-Generierung nach allen Retries fehlgeschlagen', [
'template' => $template,
'max_retries' => $this->maxRetries,
'error' => $lastException?->getMessage()
]);
throw new \RuntimeException(
'PDF-Generierung nach ' . $this->maxRetries . ' Versuchen fehlgeschlagen',
0,
$lastException
);
}
}
Memory- und Performance-Optimierung
# Symfony-Konfiguration für Production
framework:
http_client:
scoped_clients:
gotenberg.client:
base_uri: "%env(GOTENBERG_DSN)%"
timeout: 60
max_duration: 120
# Connection pooling
max_host_connections: 20
# Memory-Optimierung
buffer: false
Monitoring und Observability
<?php
namespace App\EventListener;
use Psr\Log\LoggerInterface;
use Sensiolabs\GotenbergBundle\Event\GotenbergRequestEvent;
use Sensiolabs\GotenbergBundle\Event\GotenbergResponseEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class GotenbergMetricsSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly MetricsCollector $metrics
) {}
public function onGotenbergRequest(GotenbergRequestEvent $event): void
{
$this->metrics->increment('gotenberg.request.total', [
'route' => $event->getRoute(),
'builder' => $event->getBuilderType()
]);
$event->getRequest()->attributes->set('start_time', microtime(true));
}
public function onGotenbergResponse(GotenbergResponseEvent $event): void
{
$request = $event->getRequest();
$response = $event->getResponse();
$duration = microtime(true) - $request->attributes->get('start_time');
$this->metrics->histogram('gotenberg.request.duration', $duration, [
'route' => $request->get('_route'),
'status_code' => $response->getStatusCode()
]);
$this->metrics->histogram('gotenberg.response.size',
strlen($response->getContent()), [
'route' => $request->get('_route')
]);
if ($duration > 5.0) {
$this->logger->warning('Langsame PDF-Generierung entdeckt', [
'route' => $request->get('_route'),
'duration' => $duration,
'file_size' => strlen($response->getContent())
]);
}
}
public static function getSubscribedEvents(): array
{
return [
GotenbergRequestEvent::class => 'onGotenbergRequest',
GotenbergResponseEvent::class => 'onGotenbergResponse',
];
}
}
Das Fazit: Warum das wirklich wichtig ist
Was sich für mich nach dem Wechsel zu Gotenberg geändert hat:
Vorher:
- 3 Tage für wkhtmltopdf-Setup
- 2 Stunden CSS-Debugging
- 15 Minuten pro Deployment mit Dependency-Chaos
- Ständige Angst vor “läuft auf meinem Rechner”-Syndrom
Nachher:
- 15 Minuten Gesamt-Setup-Zeit
- Perfektes CSS-Rendering out of the box
- 30 Sekunden Deployment (Docker regelt alles)
- Ruhig schlafen, weil’s überall funktioniert
Die Zahlen:
- Entwicklungszeit: 90% Reduktion
- CSS-Kompatibilitätsprobleme: eliminiert
- Deployment-Komplexität: nahe null
- Format-Support: 100+ Formate vs 1
Das SensioLabs GotenbergBundle ist nicht nur noch eine PDF-Bibliothek - es ist ein Paradigmenwechsel. Statt gegen Binary-Dependencies und CSS-Kompatibilität zu kämpfen, kriegst du eine battle-tested, produktionsbereite Lösung, die einfach funktioniert.
Dein zukünftiges Ich wird dir für diese Entscheidung danken. Glaub mir, ich war da.
Nächstes Mal: Wir erkunden erweiterte Gotenberg-Features wie PDF/A-Compliance, digitale Signaturen und den Bau eines kompletten Dokumentmanagementsystems. Aber ehrlich? Schon dieses Basic-Setup zum Laufen zu kriegen löst 90% deiner PDF-Probleme.
Was ist deine PDF-Horrorgeschichte? Ich würd gern von deinen wkhtmltopdf-Nightmares in den Kommentaren hören.