Gotenberg Bundle: Die PDF-Lösung, die wirklich funktioniert

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.