PDF-Generierung in PHP
Technologie & Architektur

PDF-Generierung in PHP: Rechnungen, Reports, Verträge

Carola Schulte
29. September 2025
18 min

"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

LibraryAnsatzGrößeStärkenSchwächen
TCPDFProgrammatisch~20 MBFeature-reich, Barcodes, UTF-8Komplex, langsam
FPDFProgrammatisch~600 KBSchnell, einfachKein UTF-8 nativ
DompdfHTML → PDF~5 MBHTML/CSS-SupportCSS-Limits, langsam
wkhtmltopdfWebkit-EngineBinaryPixel-perfektExterne Dependency
GotenbergChrome/DockerContainerModernste Render-EngineInfrastruktur 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

Carola Schulte

Ü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