Gotenberg Bundle: The PDF Solution That Actually Works

Gotenberg Bundle: The PDF Solution That Actually Works

Or: How I Learned to Stop Fighting wkhtmltopdf and Love Docker

Three days. That’s how long I spent trying to get wkhtmltopdf working in production. Binary dependencies that worked on my machine but failed in Docker. CSS Flexbox layouts that rendered perfectly in browsers but looked like garbage in PDFs. And don’t even get me started on the SSL certificate nightmare when trying to render HTTPS pages.

Then I discovered Gotenberg and the SensioLabs GotenbergBundle. 15 minutes later, I had PDFs that looked exactly like my web pages, worked in any environment, and handled 100+ document formats I never knew I needed.

Here’s how you can skip the pain and go straight to the solution that works.

The PDF Generation Problem We All Know

Let’s be honest - PDF generation in PHP has always sucked. We’ve all been there:

Option 1: Native PHP libraries like TCPDF or mPDF. Great if you love writing PDFs like it’s 1995, with manual positioning and praying your table doesn’t break across pages.

Option 2: wkhtmltopdf. Amazing results… when it works. But try deploying it:

# The dance we all know
apt-get update && apt-get install -y \
    wkhtmltopdf \
    xvfb \
    libfontconfig \
    fontconfig \
    # ... 47 other dependencies

# Still doesn't work? Try this magic incantation:
xvfb-run -a --server-args="-screen 0, 1024x768x24" wkhtmltopdf ...

Option 3: Puppeteer/Browsershot. Perfect output, but now you need Node.js, Chrome, and enough RAM to power a small datacenter.

Option 4: Cloud services. Works great until you get the bill or need to handle sensitive documents offline.

Enter Gotenberg: The Game Changer

Gotenberg is a Docker-based API that wraps Chromium, LibreOffice, and PDF manipulation tools into a single, RESTful service. Think of it as a Swiss Army knife for document conversion that actually fits in your pocket.

The magic: Instead of installing dependencies on your server, you run Gotenberg as a container. Your Symfony app just makes HTTP requests to convert documents. That’s it.

The kicker: It’s been battle-tested with over 70 million Docker Hub pulls.

Quick Win: From Zero to PDF in 5 Minutes

Let’s prove this works before diving deep. Here’s how to get your first PDF in under 5 minutes:

Step 1: Start Gotenberg

# One command to rule them all
docker run --rm -p 3000:3000 gotenberg/gotenberg:8

Step 2: Install the Bundle

composer require sensiolabs/gotenberg-bundle

Step 3: Configure (if you’re not using Flex)

# config/packages/sensiolabs_gotenberg.yaml
framework:
  http_client:
    scoped_clients:
      gotenberg.client:
        base_uri: "http://localhost:3000"

sensiolabs_gotenberg:
  http_client: "gotenberg.client"

Step 4: Generate Your First PDF

<?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 downloads immediately
    }
}

Hit /pdf/demo and boom - you’ve got a perfect PDF of the Symfony homepage. No binary dependencies, no configuration hell, no sleepless nights.

The Real World: Building a Production Invoice System

Now let’s build something real. Here’s how I use Gotenberg for generating invoices in a Symfony app:

The 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 for consistent output
                ->paperSize('A4')
                ->margins(15, 15, 15, 15) // mm
                ->preferCssPageSize(false)
                ->printBackground(true)
                // Handle slow-loading elements
                ->waitDelay(1000) // ms
                ->waitForExpression('window.invoiceReady === true')
                ->generate();

            return $pdf
                ->stream()
                ->setContentDisposition(
                    ResponseHeaderBag::DISPOSITION_ATTACHMENT,
                    sprintf('invoice-%s.pdf', $invoice->getNumber())
                );

        } catch (\Exception $e) {
            $this->logger->error('PDF generation failed', [
                'invoice_id' => $invoice->getId(),
                'error' => $e->getMessage()
            ]);

            throw new \RuntimeException('Unable to generate PDF', 0, $e);
        }
    }
}

The Twig Template

{# templates/invoice/pdf.html.twig #}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>Invoice {{ 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">
                Invoice #{{ invoice.number }}
            </div>
            <div>
                <strong>Date:</strong> {{ invoice.date|date('Y-m-d') }}<br>
                <strong>Due:</strong> {{ invoice.dueDate|date('Y-m-d') }}<br>
                <strong>Status:</strong> {{ invoice.status }}
            </div>
        </div>
    </header>

    <main>
        <div class="billing-info">
            <h3>Bill To:</h3>
            <strong>{{ invoice.client.name }}</strong><br>
            {{ invoice.client.address|nl2br }}
        </div>

        <table class="items-table">
            <thead>
                <tr>
                    <th>Description</th>
                    <th style="text-align: right;">Qty</th>
                    <th style="text-align: right;">Rate</th>
                    <th style="text-align: right;">Amount</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>Total</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 to Gotenberg that rendering is complete
        window.invoiceReady = true;
    </script>
</body>
</html>

The gotenberg_asset() magic: This Twig function automatically includes local assets (images, CSS, fonts) in the PDF request. No more broken images or missing styles.

Advanced Techniques That’ll Blow Your Mind

Multi-Document PDFs

Need to merge multiple documents? Gotenberg has you covered:

<?php

public function generateCompanyReport(Company $company): Response
{
    // Generate individual PDFs
    $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();

    // Merge them all together
    $merged = $this->gotenberg->merge()
        ->files(
            $coverPage->getContent(),
            $financialReport->getContent(),
            $appendix->getContent()
        )
        ->generate();

    return $merged->stream();
}

Office Document Conversion

Remember those 100+ formats I mentioned? Here’s how easy it is:

<?php

#[Route('/convert/office', name: 'office_convert')]
public function convertOfficeDocument(Request $request): Response
{
    $uploadedFile = $request->files->get('document');

    if (!$uploadedFile) {
        throw new BadRequestHttpException('No file uploaded');
    }

    // Gotenberg handles Word, Excel, PowerPoint, LibreOffice...
    // Even obscure formats like .pages and .numbers
    $pdf = $this->gotenberg->office()
        ->files($uploadedFile->getPathname())
        // Office-specific options
        ->landscape(false)
        ->pageRange('1-10') // Only convert first 10 pages
        ->nativePageRanges('1-3,7,9-10') // Complex ranges
        ->generate();

    return $pdf
        ->save(sprintf('%s/converted/%s.pdf',
            $this->projectDir,
            $uploadedFile->getClientOriginalName()
        ))
        ->stream();
}

Async Processing with Webhooks

For heavy documents, use webhooks to avoid timeouts:

<?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(); // Returns immediately

    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 generation failed', [
            'report_id' => $report->getId(),
            'error' => $request->getContent()
        ]);
    } else {
        // Save the PDF content
        $pdfContent = $request->getContent();
        $this->s3->upload("reports/{$report->getId()}.pdf", $pdfContent);
        $report->setStatus('completed');
    }

    $this->entityManager->flush();

    return new Response('OK');
}

The Alternatives (And Why I Chose Gotenberg)

Let me be brutally honest about your options:

TCPDF

Pros:

  • No dependencies
  • Handles complex layouts well
  • Unicode support

Cons:

  • Feels like coding PDFs in assembly language
  • No HTML/CSS support
  • Learning curve steeper than Everest

Verdict: Great if you’re building PDFs from scratch and love pain

Dompdf

Pros:

  • Simple HTML-to-PDF conversion
  • Lightweight
  • Easy to understand

Cons:

  • CSS support is basically CSS 2.0
  • No Flexbox, no Grid, no modern anything
  • Slow with complex layouts

Verdict: Perfect for simple receipts and basic reports

mPDF

Pros:

  • Better CSS support than Dompdf
  • Good documentation
  • Handles most common use cases

Cons:

  • Still no modern CSS features
  • Performance gets sluggish with large documents
  • The maintainer says it’s “dated” on the homepage

Verdict: The safe middle ground, but showing its age

wkhtmltopdf

Pros:

  • Excellent CSS support
  • Fast rendering
  • Battle-tested

Cons:

  • Binary dependency hell
  • No Flexbox support (uses old QtWebKit)
  • SSL certificate nightmares
  • Deployment complexity

Verdict: Would be perfect if it wasn’t 2025

Browsershot/Puppeteer

Pros:

  • Perfect modern CSS support
  • Chrome’s rendering engine
  • JavaScript support

Cons:

  • Requires Node.js
  • High memory usage
  • Larger file sizes
  • Complex setup

Verdict: Excellent choice if you can handle the dependencies

Gotenberg

Pros:

  • All the benefits of Puppeteer + LibreOffice + PDF tools
  • Container-based (no dependency hell)
  • RESTful API (language agnostic)
  • 100+ document formats
  • Production-ready webhooks
  • Active development

Cons:

  • Requires Docker
  • Network dependency (mitigated by running locally)
  • Learning curve for advanced features

Verdict: The pragmatic choice for modern applications

Production Considerations (The Stuff That Matters at 3 AM)

Docker Configuration for Production

# docker-compose.yml
version: "3.8"

services:
  gotenberg:
    image: gotenberg/gotenberg:8
    restart: unless-stopped
    command:
      - "gotenberg"
      # Security: Disable dangerous features in production
      - "--api-disable-health-check-logging"
      # Performance: Optimize for your load
      - "--chromium-max-queue-size=50"
      - "--libreoffice-max-queue-size=20"
      # Memory: Prevent OOM kills
      - "--chromium-restart-after=100"
      - "--libreoffice-restart-after=50"
      # Network: Handle SSL issues
      - "--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

Handling SSL Certificate Issues

If your Symfony app runs on HTTPS with self-signed certificates:

# config/packages/sensiolabs_gotenberg.yaml
sensiolabs_gotenberg:
  request_context:
    # Tell Gotenberg how to reach your app from inside Docker
    base_uri: "http://host.docker.internal:8080"
  default_options:
    html:
      # Handle HTTPS issues
      wait_timeout: 30
      wait_delay: 1000

Error Handling Strategy

<?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);

                // Apply options
                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 generation failed, retrying', [
                    '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 generation failed after all retries', [
            'template' => $template,
            'max_retries' => $this->maxRetries,
            'error' => $lastException?->getMessage()
        ]);

        throw new \RuntimeException(
            'PDF generation failed after ' . $this->maxRetries . ' attempts',
            0,
            $lastException
        );
    }
}

Memory and Performance Optimization

# Symfony configuration for 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 optimization
        buffer: false

Monitoring and 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('Slow PDF generation detected', [
                'route' => $request->get('_route'),
                'duration' => $duration,
                'file_size' => strlen($response->getContent())
            ]);
        }
    }

    public static function getSubscribedEvents(): array
    {
        return [
            GotenbergRequestEvent::class => 'onGotenbergRequest',
            GotenbergResponseEvent::class => 'onGotenbergResponse',
        ];
    }
}

The Bottom Line: Why This Actually Matters

Here’s what changed for me after switching to Gotenberg:

Before:

  • 3 days to set up wkhtmltopdf
  • 2 hours debugging CSS issues
  • 15 minutes per deployment dealing with dependencies
  • Constant fear of “works on my machine” syndrome

After:

  • 15 minutes total setup time
  • Perfect CSS rendering out of the box
  • 30 seconds to deploy (Docker handles everything)
  • Sleep soundly knowing it works everywhere

The numbers:

  • Development time: 90% reduction
  • CSS compatibility issues: eliminated
  • Deployment complexity: near zero
  • Format support: 100+ formats vs 1

The SensioLabs GotenbergBundle isn’t just another PDF library - it’s a paradigm shift. Instead of fighting binary dependencies and CSS compatibility, you get a battle-tested, production-ready solution that just works.

Your future self will thank you for making this choice. Trust me, I’ve been there.


Next time: We’ll explore advanced Gotenberg features like PDF/A compliance, digital signatures, and building a complete document management system. But honestly? Just getting this basic setup working will solve 90% of your PDF problems.

What’s your PDF horror story? I’d love to hear about your wkhtmltopdf nightmares in the comments.