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.