Zum Inhalt springen
Abstrakte Formen aus Mattglas: kleine Blöcke fallen passgenau in Vertiefungen einer Platte

Amtliche PDF-Formulare exakt befüllen: FPDI statt HTML-zu-PDF

Amtliche PDF-Formulare befüllen Sie in PHP positionsgenau, indem FPDI die Originalseite als Vorlage importiert und FPDF die Werte an festen Millimeter-Koordinaten darüberschreibt. Das Formular bleibt dabei unverändert, nur die Einträge kommen hinzu.

8 min Lesezeit Laravel & PHP

Ein amtliches Formular hat eine Gestalt, die eine Behörde festgelegt hat: Kästchen, Linien, Fußzeilen mit Formularnummer und Stand. Wer es in einer Webanwendung ausfüllen lässt, soll genau dieses Blatt abgeben, mit Name, Datum und Kreuz an der richtigen Stelle. Ein nachgebautes Formular, das ähnlich aussieht, genügt dafür nicht.

Die Anleitung unten führt durch den Weg mit den PHP-Bibliotheken FPDI und FPDF, mit getestetem Code für Text, Datum, Ankreuzfelder und mehrseitige Vorlagen. Sie gilt für Formulare als flache PDF-Seite. Für ausfüllbare Formulare mit eingebauten Feldern stehen die Wege in den Häufigen Fragen.

DomPDF

Warum HTML-zu-PDF hier scheitert

DomPDF beschreibt sich selbst als Konverter von HTML zu PDF. Es baut aus HTML und CSS ein neues Dokument, mit eigener Layout-Engine. Für Rechnungen, Berichte und Bestätigungen mit eigenem Layout ist das der bequemere Weg, weil ein Blade-Template genügt.

Bei einem vorgegebenen Formular passt das Prinzip nicht. Um ein Behördenblatt nachzubauen, müssten Sie jede Linie und jedes Kästchen in HTML und CSS nachzeichnen. Schon kleine Unterschiede bei Schriftmetrik, Seitenrand oder Zeilenhöhe verschieben Einträge um Millimeter, und ein Feld rutscht über die Kante. Ändert die Behörde das Formular, beginnt die Arbeit von vorn. Dazu kommen die bekannten Grenzen von DomPDF: Laut Beschreibung auf GitHub unterstützt es weder Flexbox noch CSS Grid, und eine Tabellenzeile muss auf eine Seite passen.

FPDI geht den umgekehrten Weg. Es liest eine Seite aus einem bestehenden PDF und stellt sie als Vorlage bereit, die Sie wie ein Bild auf eine neue Seite legen. Darüber schreibt FPDF die Werte. Die Seite selbst wird dabei nicht neu gesetzt, sie bleibt so, wie die Behörde sie veröffentlicht hat.

Diese Arbeitsteilung nutzen wir im Taxifahrer-Portal der Münchner Taxi-Genossenschaft, das seit Juli 2025 produktiv läuft: Die PDF-Templates befüllen wir dort auf Low-Level mit FPDI und FPDF, für die freieren Layouts läuft DomPDF parallel.

Eine Schreibmaschine setzt jeden Eintrag genau in das vorgedruckte Feld des Formulars, das ausgefüllte Feld ist orange.

Koordinaten

Vorlage laden und Koordinaten bestimmen

Beide Bibliotheken kommen per Composer: setasign/fpdi und setasign/fpdf. Am 01.09.2026 waren FPDI 2.6.8 und FPDF 1.9 aktuell, beide unter MIT-Lizenz. FPDF rechnet standardmäßig in Millimetern auf A4, der Nullpunkt liegt oben links. Das passt zu einem Formular, das Sie mit dem Lineal ausmessen.

Genauer als das Lineal ist das PDF selbst. Das Werkzeug pdftotext aus den Poppler-Werkzeugen gibt mit der Option -bbox die Lage jedes Wortes aus, allerdings in Punkt. Ein Punkt ist 1/72 Zoll, also 25,4 geteilt durch 72, rund 0,353 Millimeter. Meldet pdftotext für die Beschriftung „Name, Vorname“ die Unterkante yMax gleich 115,0 Punkt, liegt sie 115,0 mal 0,353, also rund 40,6 Millimeter unter der Oberkante des Blatts. Das Eingabefeld darunter beginnt dann ein bis zwei Millimeter tiefer. Messen Sie jedes Feld einmal, tragen Sie die Werte in eine Tabelle im Code ein und schreiben Sie nichts per Augenmaß.

Zwei Befehle von FPDF setzen Text unterschiedlich. Text(x, y, ...) schreibt mit der Grundlinie auf Höhe y, Buchstaben ragen also nach oben. Cell() beginnt mit der linken oberen Ecke bei der Position, die SetXY() gesetzt hat, und zentriert den Text vertikal in der angegebenen Höhe; links bleibt ein Innenabstand von einem Millimeter. Wer beide mischt, sucht später nach zwei Millimetern Versatz. Der Code unten verwendet durchgehend SetXY() und Cell().

Code

Text, Datum und Ankreuzfelder setzen

Das Skript importiert jede Seite der Vorlage, legt sie in Originalgröße auf eine neue Seite und schreibt die Werte darüber. Feldpositionen stehen getrennt von den Daten, damit eine neue Formularversion nur die Tabelle ändert. Das Datum kommt als Objekt und wird erst beim Schreiben im deutschen Format ausgegeben. Bei den Ankreuzfeldern bekommt nur das Kästchen ein X, das zur gewählten Antragsart gehört.

Getestet unter macOS mit PHP 8.5, setasign/fpdi 2.6.8 und setasign/fpdf 1.9.0 an einer zweiseitigen A4-Vorlage.

fill-application.php php
<?php

require __DIR__ . '/vendor/autoload.php';

use setasign\Fpdi\Fpdi;

// Field positions in millimetres, measured on the original form.
$fields = [
    1 => [
        'name' => ['x' => 22, 'y' => 43.5, 'w' => 116],
        'date' => ['x' => 22, 'y' => 61.5, 'w' => 36],
    ],
];
$checkboxes = [
    'first_application' => ['page' => 1, 'x' => 25, 'y' => 85],
    'renewal'           => ['page' => 1, 'x' => 45, 'y' => 85],
];

$data = [
    'name' => 'Müller-Lüdenscheidt, Jürgen',
    'date' => new DateTimeImmutable('2026-09-01'),
    'type' => 'renewal',
];

// FPDF core fonts only know Windows-1252, not UTF-8.
function toCp1252(string $text): string
{
    $converted = @iconv('UTF-8', 'Windows-1252', $text);
    if ($converted === false) {
        throw new RuntimeException('Character outside Windows-1252: ' . $text);
    }
    return $converted;
}

$pdf = new Fpdi();
$pdf->SetAutoPageBreak(false);
$pageCount = $pdf->setSourceFile(__DIR__ . '/templates/application.pdf');

for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
    $template = $pdf->importPage($pageNo);
    $size = $pdf->getTemplateSize($template);
    $pdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
    $pdf->useTemplate($template);

    $pdf->SetFont('Helvetica', '', 10);
    foreach ($fields[$pageNo] ?? [] as $field => $pos) {
        $value = $data[$field] instanceof DateTimeInterface
            ? $data[$field]->format('d.m.Y')
            : $data[$field];
        $pdf->SetXY($pos['x'], $pos['y']);
        $pdf->Cell($pos['w'], 5, toCp1252($value));
    }

    $pdf->SetFont('Helvetica', 'B', 10);
    foreach ($checkboxes as $type => $pos) {
        if ($pos['page'] === $pageNo && $data['type'] === $type) {
            $pdf->SetXY($pos['x'], $pos['y']);
            $pdf->Cell(4, 4, 'X', 0, 0, 'C');
        }
    }
}

$pdf->Output('F', __DIR__ . '/application-filled.pdf');

Seiten und Zeichensatz

Mehrseitige Formulare und Schriften

setSourceFile() liefert die Seitenzahl der Vorlage. Die Schleife übernimmt deshalb jede Seite, auch solche ohne Einträge, und das Ergebnis hat so viele Seiten wie das Original. getTemplateSize() liest Breite, Höhe und Ausrichtung jeder Seite, sodass auch ein Formular mit einer Querformatseite in der Mitte richtig ankommt. SetAutoPageBreak(false) verhindert, dass FPDF bei einem Eintrag nahe am unteren Rand eine leere Folgeseite anlegt.

Eine Stolperstelle sind Umlaute. Die eingebauten Schriften von FPDF wie Helvetica erwarten laut FPDF-FAQ Text in Windows-1252, nicht in UTF-8. Übergibt man den UTF-8-String aus Laravel direkt, steht im Formular „Müller“ statt „Müller“. Die FAQ nennt zwei Umwandlungen, iconv() und mb_convert_encoding(). Der Code oben nutzt iconv() ohne Zusatz, weil es bei einem Zeichen, das Windows-1252 nicht kennt, false zurückgibt; die Funktion toCp1252() bricht dann mit einer Ausnahme ab. mb_convert_encoding() ersetzt solche Zeichen stillschweigend durch ein Fragezeichen, und ein Name wie „Łukasz“ landete unbemerkt verstümmelt auf dem Amtsblatt.

Brauchen Sie Zeichen außerhalb von Windows-1252, etwa polnische oder türkische Buchstaben in Namen, ist tFPDF der Weg. Diese Variante von FPDF verarbeitet UTF-8 und bettet TrueType-Schriften ein, und zwar nur die verwendeten Zeichen. FPDI bringt dafür eine eigene Klasse mit, setasign\Fpdi\Tfpdf\Fpdi. Die Schrift laden Sie mit AddFont(), wobei der vierte Parameter auf true steht; tFPDF braucht die PHP-Erweiterung mbstring. Wählen Sie eine Schrift, deren Lizenz das Einbetten erlaubt.

Eine Grenze betrifft die Vorlage selbst. Laut FPDI-Handbuch liest die freie Version keine PDFs mit komprimierten Querverweis-Tabellen und Objekt-Streams, die mit PDF 1.5 eingeführt wurden. FPDI bricht dann mit einer CrossReferenceException ab, deren Meldung auf „a compression technique which is not supported by the free parser“ verweist. Verschlüsselte Dokumente liest die freie Version ebenfalls nicht. Für beides verkauft der Hersteller Setasign das kommerzielle Zusatzmodul FPDI PDF-Parser. Ohne Zusatzmodul hilft es, die Vorlage einmalig mit Ghostscript als PDF 1.4 neu zu schreiben (Befehl unten). Das neue Dokument ist dann eine Neufassung, die Sie wie jede Vorlagenänderung gegen das Original prüfen.

Nur bei Vorlagen mit komprimierten Querverweisen nötig; schreibt die Vorlage einmalig als PDF 1.4 neu.

convert-template.sh bash
gs -q -o templates/application-14.pdf -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 templates/application.pdf

Abnahme

Testen gegen das Original

Ein befülltes Formular gilt erst als fertig, wenn jeder Wert nachweislich im richtigen Kästchen steht. Dafür eignen sich drei Prüfungen, von schnell bis gründlich.

  1. Lage messen: pdftotext -bbox gibt die Lage jedes geschriebenen Werts aus. Das Skript unten rechnet Punkt in Millimeter um. Liegt der Wert innerhalb des abgemessenen Kästchens, ist der Test bestanden. Diese Prüfung lässt sich als automatischer Test hinterlegen, der bei jeder Codeänderung läuft.

  2. Grenzfälle füllen: der längste denkbare Name, ein Doppelname mit Bindestrich, ein Datum am Monatsende, jede Kombination von Kreuzen. Cell() schneidet zu langen Text nicht ab, er läuft über das Kästchen hinaus. Legen Sie deshalb für jedes Feld eine Höchstlänge fest und prüfen Sie sie mit GetStringWidth(), bevor Sie schreiben.

  3. Ausdruck gegen das Licht: Drucken Sie das Ergebnis in tatsächlicher Größe, ohne „An Seite anpassen“, und legen Sie es auf ein ausgefülltes Muster der Behörde. Verschiebungen um einen Millimeter sehen Sie sofort.

Die Prüfung in Schritt 1 sah im Test für die Beispielvorlage so aus: Der Name beginnt bei 22,9 Millimetern von links, also an der Position 22 Millimeter aus der Feldtabelle plus dem Innenabstand von Cell() und damit im Kästchen ab 20 Millimetern, und das X liegt bei 45,5 Millimetern im Kästchen von 45 bis 49 Millimetern.

Gibt die Lage der geschriebenen Werte in Millimetern aus; unter Windows in Git Bash oder WSL mit Poppler ausführen.

check-positions.sh bash
pdftotext -bbox application-filled.pdf - \
  | grep -E '>(Müller-Lüdenscheidt,|01\.09\.2026|X)<' \
  | sed -E 's/.*xMin="([0-9.]+)" yMin="([0-9.]+)".*>(.*)<\/word>/\3 \1 \2/' \
  | awk '{ printf "%-22s x=%6.1f mm  y=%6.1f mm\n", $1, $2/72*25.4, $3/72*25.4 }'

Einbau

Vom Skript zur Funktion in der Anwendung

In einer Laravel-Anwendung gehört das Skript in eine eigene Klasse je Formular, mit der Feldtabelle als Konstante und den Daten aus dem Modell. Statt in eine Datei zu schreiben, liefert Output('S') das PDF als String, den ein Controller mit dem Content-Type application/pdf zurückgibt oder den die Anwendung als Dokument am Datensatz ablegt. Die Vorlage liegt versioniert im Repository; erscheint eine neue Formularversion, kommen neue Vorlage und neue Feldtabelle im selben Commit.

Wie wir Fachanwendungen mit solchen Bausteinen planen und bauen, steht auf der Seite zu Webanwendungen und individueller Software.

PDF in PHP

Häufige Fragen

  • Nein. DomPDF wandelt HTML und CSS in ein neues PDF um und hat keine Funktion, eine vorhandene PDF-Datei zu öffnen oder zu verändern. Für vorgegebene Formulare brauchen Sie eine Bibliothek, die bestehende Seiten importiert, etwa FPDI. Beide lassen sich in derselben Anwendung kombinieren: DomPDF für eigene Layouts, FPDI und FPDF für fremde Vorlagen.

  • Nur eingeschränkt. Laut FPDI-Handbuch übernimmt FPDI beim Import nur den Inhalt der Seite selbst; Formularfelder samt eingetragenen Werten und andere Anmerkungen gehen verloren. Für ein ausfüllbares Formular gibt es zwei Wege: die Felder vorher in festen Seiteninhalt umwandeln (flatten) und die Seite dann mit FPDI überschreiben, oder die Felder direkt befüllen, etwa mit dem kommerziellen SetaPDF-FormFiller von Setasign, der AcroForm-Felder ausfüllt und auf Wunsch in statischen Inhalt umwandelt.

Ein Formular, das jede Behörde annimmt

Füllen Ihre Mitarbeiter Behördenformulare noch von Hand aus oder tippen Werte aus der Anwendung ab? Wie amtliche Vorlagen, Mitgliederdaten und Online-Zahlungen in einem Portal zusammenkommen, zeigt die Projektseite der Münchner Taxi-Genossenschaft.

Zum Projekt Taxifahrer-Portal

Nachweise

Quellen und Stand

Stand: 01.09.2026. Versionen laut Packagist und Changelog zum Beitragsdatum; der Code wurde unter macOS mit PHP 8.5 und den genannten Versionen an einer selbst erzeugten zweiseitigen Vorlage ausgeführt, das Ergebnis mit pdftotext aus Poppler geprüft und die Umwandlung mit Ghostscript getestet.