PDF-Generierung in PHP: Rechnungen, Reports, Verträge
"Können wir das auch als PDF exportieren?" – Diese Frage höre ich in fast jedem Projekt. Rechnungen, Reports, Verträge, Lieferscheine – irgendwann braucht jede Business-App PDF-Export. Nach hunderten generierten PDFs zeige ich, welche Library wann die richtige Wahl ist.
TL;DR – Die Kurzfassung
- TCPDF: Für komplexe Layouts, Tabellen, Barcodes – der Allrounder
- FPDF: Leichtgewicht für simple PDFs – 600 KB statt 20 MB
- Dompdf: HTML/CSS zu PDF – wenn Designer mitarbeiten
- wkhtmltopdf: Pixel-perfekte Konvertierung – für komplexe HTML-Reports
- Gotenberg: Headless Chrome als Service – für moderne Web-Layouts
Die Libraries im Vergleich
| Library | Ansatz | Größe | Stärken | Schwächen |
|---|---|---|---|---|
| TCPDF | Programmatisch | ~20 MB | Feature-reich, Barcodes, UTF-8 | Komplex, langsam |
| FPDF | Programmatisch | ~600 KB | Schnell, einfach | Kein UTF-8 nativ |
| Dompdf | HTML → PDF | ~5 MB | HTML/CSS-Support | CSS-Limits, langsam |
| wkhtmltopdf | Webkit-Engine | Binary | Pixel-perfekt | Externe Dependency |
| Gotenberg | Chrome/Docker | Container | Modernste Render-Engine | Infrastruktur nötig |
TCPDF: Der Allrounder für Business-Dokumente
TCPDF ist meine erste Wahl für Rechnungen, Angebote und offizielle Dokumente. Volle Kontrolle über jedes Pixel, Barcodes/QR-Codes eingebaut, UTF-8-Support.
Installation
composer require tecnickcom/tcpdf
Beispiel: Professionelle Rechnung
<?php
use TCPDF;
class InvoicePdfGenerator
{
private TCPDF $pdf;
public function generate(Invoice $invoice): string
{
$this->pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8');
// Metadaten
$this->pdf->SetCreator('Ihre Firma');
$this->pdf->SetAuthor('Rechnungssystem');
$this->pdf->SetTitle('Rechnung ' . $invoice->number);
// Header/Footer deaktivieren für eigenes Layout
$this->pdf->setPrintHeader(false);
$this->pdf->setPrintFooter(false);
// Margins
$this->pdf->SetMargins(20, 20, 20);
$this->pdf->SetAutoPageBreak(true, 25);
$this->pdf->AddPage();
$this->renderHeader($invoice);
$this->renderAddresses($invoice);
$this->renderLineItems($invoice);
$this->renderTotals($invoice);
$this->renderFooter($invoice);
return $this->pdf->Output('', 'S'); // String zurückgeben
}
private function renderHeader(Invoice $invoice): void
{
// Logo
$this->pdf->Image('/path/to/logo.png', 20, 20, 50);
// Rechnungsnummer rechts
$this->pdf->SetFont('helvetica', 'B', 24);
$this->pdf->SetXY(120, 20);
$this->pdf->Cell(70, 10, 'RECHNUNG', 0, 1, 'R');
$this->pdf->SetFont('helvetica', '', 11);
$this->pdf->SetXY(120, 32);
$this->pdf->Cell(70, 6, 'Nr: ' . $invoice->number, 0, 1, 'R');
$this->pdf->SetX(120);
$this->pdf->Cell(70, 6, 'Datum: ' . $invoice->date->format('d.m.Y'), 0, 1, 'R');
}
private function renderLineItems(Invoice $invoice): void
{
$this->pdf->SetY(100);
// Tabellenkopf
$this->pdf->SetFont('helvetica', 'B', 10);
$this->pdf->SetFillColor(26, 53, 94); // Brand-Farbe
$this->pdf->SetTextColor(255, 255, 255);
$this->pdf->Cell(80, 8, 'Beschreibung', 1, 0, 'L', true);
$this->pdf->Cell(25, 8, 'Menge', 1, 0, 'C', true);
$this->pdf->Cell(35, 8, 'Einzelpreis', 1, 0, 'R', true);
$this->pdf->Cell(30, 8, 'Gesamt', 1, 1, 'R', true);
// Zeilen
$this->pdf->SetFont('helvetica', '', 10);
$this->pdf->SetTextColor(0, 0, 0);
$fill = false;
foreach ($invoice->items as $item) {
$this->pdf->SetFillColor(249, 250, 251);
$this->pdf->Cell(80, 7, $item->description, 'LR', 0, 'L', $fill);
$this->pdf->Cell(25, 7, $item->quantity, 'LR', 0, 'C', $fill);
$this->pdf->Cell(35, 7, number_format($item->unitPrice, 2, ',', '.') . ' €', 'LR', 0, 'R', $fill);
$this->pdf->Cell(30, 7, number_format($item->total, 2, ',', '.') . ' €', 'LR', 1, 'R', $fill);
$fill = !$fill;
}
// Abschlusslinie
$this->pdf->Cell(170, 0, '', 'T');
}
// Hinweis: Für variable Zeilenhöhen (lange Beschreibungen) sollte MultiCell()
// mit manueller Spaltenberechnung verwendet werden – sonst zerreißt das
// Layout bei Seitenumbrüchen. Cell() bricht nicht sauber über Seiten.
private function renderTotals(Invoice $invoice): void
{
$this->pdf->Ln(5);
$x = 120;
$this->pdf->SetFont('helvetica', '', 10);
$this->pdf->SetX($x);
$this->pdf->Cell(35, 6, 'Netto:', 0, 0, 'R');
$this->pdf->Cell(35, 6, number_format($invoice->netTotal, 2, ',', '.') . ' €', 0, 1, 'R');
$this->pdf->SetX($x);
$this->pdf->Cell(35, 6, 'MwSt. 19%:', 0, 0, 'R');
$this->pdf->Cell(35, 6, number_format($invoice->vatAmount, 2, ',', '.') . ' €', 0, 1, 'R');
$this->pdf->SetFont('helvetica', 'B', 12);
$this->pdf->SetX($x);
$this->pdf->Cell(35, 8, 'Gesamt:', 0, 0, 'R');
$this->pdf->Cell(35, 8, number_format($invoice->grossTotal, 2, ',', '.') . ' €', 0, 1, 'R');
}
}
QR-Code für Zahlungen (EPC-QR)
// EPC-QR-Code für Banking-Apps
$qrData = implode("\n", [
'BCD', // Service Tag
'002', // Version
'1', // Encoding (UTF-8)
'SCT', // SEPA Credit Transfer
$invoice->bic, // BIC
$invoice->recipientName, // Empfänger
$invoice->iban, // IBAN
'EUR' . $invoice->grossTotal, // Betrag
'', // Purpose
$invoice->number, // Verwendungszweck
'Rechnung ' . $invoice->number // Hinweis
]);
$this->pdf->write2DBarcode($qrData, 'QRCODE,H', 150, 220, 35, 35);
Dompdf: Wenn Designer HTML liefern
Dompdf konvertiert HTML/CSS zu PDF. Ideal, wenn Designer Templates als HTML bauen oder bestehende Web-Views als PDF exportiert werden sollen.
Installation
composer require dompdf/dompdf
Beispiel: Report aus HTML-Template
<?php
use Dompdf\Dompdf;
use Dompdf\Options;
class HtmlReportGenerator
{
public function generate(array $data): string
{
$options = new Options();
$options->set('isHtml5ParserEnabled', true);
$options->set('isPhpEnabled', false); // Security!
$options->set('isRemoteEnabled', false); // Keine externen Ressourcen
$options->set('defaultFont', 'DejaVu Sans');
$dompdf = new Dompdf($options);
// HTML aus Template generieren
$html = $this->renderTemplate('report.html.php', $data);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
return $dompdf->output();
}
private function renderTemplate(string $template, array $data): string
{
ob_start();
extract($data);
include __DIR__ . '/templates/' . $template;
return ob_get_clean();
}
}
Das HTML-Template (report.html.php)
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@page {
margin: 20mm;
}
body {
font-family: 'DejaVu Sans', sans-serif;
font-size: 11pt;
line-height: 1.4;
}
.header {
border-bottom: 2px solid #1a355e;
padding-bottom: 10mm;
margin-bottom: 10mm;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
background-color: #1a355e;
color: white;
padding: 8px;
text-align: left;
}
td {
padding: 8px;
border-bottom: 1px solid #ddd;
}
.page-break {
page-break-after: always;
}
.footer {
position: fixed;
bottom: 0;
width: 100%;
font-size: 9pt;
color: #666;
}
</style>
</head>
<body>
<div class="header">
<h1>Monatsbericht <?= htmlspecialchars($month) ?></h1>
</div>
<table>
<thead>
<tr>
<th>Datum</th>
<th>Beschreibung</th>
<th>Betrag</th>
</tr>
</thead>
<tbody>
<?php foreach ($entries as $entry): ?>
<tr>
<td><?= $entry['date'] ?></td>
<td><?= htmlspecialchars($entry['description']) ?></td>
<td><?= number_format($entry['amount'], 2, ',', '.') ?> €</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="footer">
Erstellt am <?= date('d.m.Y H:i') ?>
<!-- Seitenzahlen in Dompdf über $pdf->page_script() lösen, nicht über HTML -->
</div>
</body>
</html>
CSS-Einschränkungen bei Dompdf
Funktioniert:
- Basis-CSS (margin, padding, border, background)
- Tabellen-Layout
- @page für Seitenränder
- page-break-before/after
- position: fixed für Header/Footer
Funktioniert NICHT:
- Flexbox
- CSS Grid
- Komplexe Selektoren
- Externe Fonts (ohne Konfiguration)
- JavaScript
FPDF: Leichtgewicht für Simple PDFs
Wenn Sie nur einfache PDFs ohne Schnickschnack brauchen: FPDF ist 600 KB statt TCPDFs 20 MB.
<?php
require 'fpdf/fpdf.php';
class SimplePdfGenerator extends FPDF
{
public function generateLabel(string $name, string $barcode): string
{
$this->AddPage('L', [100, 50]); // Querformat, 100x50mm
$this->SetFont('Arial', 'B', 14);
$this->Cell(0, 10, utf8_decode($name), 0, 1, 'C');
$this->SetFont('Arial', '', 10);
$this->Cell(0, 8, $barcode, 0, 1, 'C');
return $this->Output('S');
}
}
// Nutzung
$pdf = new SimplePdfGenerator();
$content = $pdf->generateLabel('Produkt ABC', '4006381333931');
Achtung: FPDF unterstützt kein UTF-8 nativ. utf8_decode() ist nur eine Notlösung – es zerstört alles außerhalb ISO-8859-1 (€-Zeichen, Emojis, osteuropäische Zeichen). Für echte UTF-8-Unterstützung ist tFPDF oder ein Unicode-Font via AddFont() Pflicht.
wkhtmltopdf: Pixel-perfekte HTML-Konvertierung
Wenn Dompdf an seine Grenzen stößt: wkhtmltopdf nutzt eine echte WebKit-Engine. Komplexe CSS-Layouts funktionieren.
Hinweis: wkhtmltopdf ist stabil, aber technologisch eingefroren – die WebKit-Version ist alt, CSS Grid und moderne Specs fehlen. Für moderne CSS-Layouts ist Gotenberg langfristig die bessere Wahl.
Installation (Debian/Ubuntu)
apt-get install wkhtmltopdf xvfb
# Für Server ohne Display:
xvfb-run wkhtmltopdf input.html output.pdf
PHP-Wrapper
<?php
class WkhtmlPdfGenerator
{
private string $binary = '/usr/bin/wkhtmltopdf';
public function fromHtml(string $html, array $options = []): string
{
$inputFile = tempnam(sys_get_temp_dir(), 'pdf_in_') . '.html';
$outputFile = tempnam(sys_get_temp_dir(), 'pdf_out_') . '.pdf';
file_put_contents($inputFile, $html);
$defaultOptions = [
'--page-size' => 'A4',
'--margin-top' => '20mm',
'--margin-bottom' => '20mm',
'--margin-left' => '20mm',
'--margin-right' => '20mm',
'--encoding' => 'UTF-8',
'--disable-javascript' => null,
'--no-stop-slow-scripts' => null,
];
$options = array_merge($defaultOptions, $options);
$cmd = escapeshellcmd($this->binary);
foreach ($options as $key => $value) {
$cmd .= ' ' . escapeshellarg($key);
if ($value !== null) {
$cmd .= ' ' . escapeshellarg($value);
}
}
$cmd .= ' ' . escapeshellarg($inputFile);
$cmd .= ' ' . escapeshellarg($outputFile);
// Auf headless Server
$cmd = 'xvfb-run -a ' . $cmd;
exec($cmd . ' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
throw new RuntimeException('PDF generation failed: ' . implode("\n", $output));
}
$pdf = file_get_contents($outputFile);
// Cleanup
unlink($inputFile);
unlink($outputFile);
return $pdf;
}
}
Security-Warnung: wkhtmltopdf darf niemals ungefiltertes User-HTML rendern – SSRF und lokale File-Reads sind sonst trivial möglich. Local file access muss deaktiviert bleiben (kein Zugriff auf file://). Prüft die Flags eurer wkhtmltopdf-Version und erzwingt das in der Wrapper-Konfiguration. HTML immer sanitizen.
Gotenberg: Moderner Chrome-basierter Service
Für moderne Web-Layouts mit Flexbox, Grid, Web Fonts: Gotenberg nutzt Headless Chrome in einem Docker-Container.
Docker Setup
docker run -d -p 3000:3000 gotenberg/gotenberg:8
PHP-Integration
<?php
class GotenbergClient
{
public function __construct(
private string $endpoint = 'http://localhost:3000'
) {}
public function htmlToPdf(string $html): string
{
$boundary = uniqid();
$body = "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"files\"; filename=\"index.html\"\r\n";
$body .= "Content-Type: text/html\r\n\r\n";
$body .= $html . "\r\n";
$body .= "--{$boundary}--\r\n";
$ch = curl_init($this->endpoint . '/forms/chromium/convert/html');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Content-Type: multipart/form-data; boundary={$boundary}",
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new RuntimeException('Gotenberg error: ' . $response);
}
return $response;
}
}
Produktiv-Hinweis: Gotenberg sollte niemals ungeschützt im Internet laufen – immer mit API-Token oder Network-Isolation, Rate-Limits und Größenbeschränkungen für PDFs betreiben.
Praxis-Patterns
PDF-Service mit Strategy Pattern
<?php
interface PdfGeneratorInterface
{
public function generate(DocumentInterface $document): string;
}
class PdfService
{
private array $generators = [];
public function register(string $type, PdfGeneratorInterface $generator): void
{
$this->generators[$type] = $generator;
}
public function generate(string $type, DocumentInterface $document): string
{
if (!isset($this->generators[$type])) {
throw new InvalidArgumentException("Unknown document type: {$type}");
}
return $this->generators[$type]->generate($document);
}
public function stream(string $type, DocumentInterface $document, string $filename): void
{
$pdf = $this->generate($type, $document);
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $filename . '"');
header('Content-Length: ' . strlen($pdf));
header('Cache-Control: private, max-age=0, must-revalidate');
header('X-Content-Type-Options: nosniff');
echo $pdf;
}
public function download(string $type, DocumentInterface $document, string $filename): void
{
$pdf = $this->generate($type, $document);
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . strlen($pdf));
header('X-Content-Type-Options: nosniff');
echo $pdf;
}
}
// Registrierung
$pdfService = new PdfService();
$pdfService->register('invoice', new InvoicePdfGenerator());
$pdfService->register('report', new ReportPdfGenerator());
$pdfService->register('contract', new ContractPdfGenerator());
// Nutzung
$pdfService->download('invoice', $invoice, "Rechnung-{$invoice->number}.pdf");
Async PDF-Generierung für große Reports
<?php
class AsyncPdfJob
{
public function __construct(
private PdfService $pdfService,
private StorageInterface $storage,
private NotificationService $notifications
) {}
public function handle(array $payload): void
{
$document = $this->loadDocument($payload['document_id']);
$pdf = $this->pdfService->generate($payload['type'], $document);
$path = sprintf(
'pdfs/%s/%s.pdf',
date('Y/m'),
$document->getId()
);
$this->storage->put($path, $pdf);
$this->notifications->send($payload['user_id'], [
'type' => 'pdf_ready',
'message' => 'Ihr PDF wurde erstellt',
'download_url' => $this->storage->temporaryUrl($path, now()->addHour()),
]);
}
}
Performance-Tipps
1. Font-Caching bei TCPDF
// Fonts einmalig konvertieren, nicht bei jedem Request
define('K_PATH_FONTS', '/var/cache/tcpdf/fonts/');
// Custom Font einmalig hinzufügen
TCPDF_FONTS::addTTFfont('/path/to/font.ttf', 'TrueTypeUnicode', '', 96);
2. Template-Caching bei Dompdf
$options->set('fontCache', '/var/cache/dompdf/fonts');
$options->set('tempDir', '/var/cache/dompdf/tmp');
3. PDF nur bei Änderung neu generieren
public function getCachedPdf(Invoice $invoice): string
{
$cacheKey = sprintf('pdf:invoice:%s:%s',
$invoice->id,
$invoice->updated_at->timestamp
);
return $this->cache->remember($cacheKey, 3600, function () use ($invoice) {
return $this->generator->generate($invoice);
});
}
Entscheidungshilfe: Welche Library?
┌─────────────────────────────────────────────────────────────────┐
│ PDF-LIBRARY ENTSCHEIDUNG │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Brauchen Sie pixel-genaue Kontrolle? │
│ ├── JA → TCPDF (Rechnungen, Verträge, Formulare) │
│ └── NEIN ↓ │
│ │
│ Haben Sie bereits HTML-Templates? │
│ ├── JA → Flexbox/Grid nötig? │
│ │ ├── JA → Gotenberg oder wkhtmltopdf │
│ │ └── NEIN → Dompdf │
│ └── NEIN ↓ │
│ │
│ Sind die PDFs sehr einfach (Labels, Etiketten)? │
│ ├── JA → FPDF (minimaler Footprint) │
│ └── NEIN → TCPDF │
│ │
└─────────────────────────────────────────────────────────────────┘
Fazit
PDF-Generierung in PHP ist ein gelöstes Problem – aber die Wahl der richtigen Library macht den Unterschied:
- TCPDF für Business-Dokumente mit voller Kontrolle
- Dompdf wenn Designer HTML/CSS liefern
- FPDF für simple, schnelle PDFs
- wkhtmltopdf/Gotenberg für komplexe Web-Layouts
Mein Stack: TCPDF für Rechnungen/Verträge, Gotenberg für Reports mit modernem CSS. Diese Kombination deckt 95% aller Anforderungen ab.
Weiterführende Links
Über Carola Schulte
Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.
PDF-Export für Ihre App?
Lassen Sie uns besprechen, wie ich Rechnungen, Reports oder Verträge für Ihre Anwendung umsetze – kostenlos und unverbindlich.
Kostenloses Erstgespräch