ZUGFeRD mit PHP: Wie ich das horstoeko/zugferd-Paket lokal vorbereitet und ohne Composer-Zugriff auf den Server gebracht habe

Wer schon einmal versucht hat, das ZUGFeRD-Format mit PHP umzusetzen, wird früher oder später auf das Projekt horstoeko/zugferd stoßen. Es bietet eine mächtige Möglichkeit, ZUGFeRD-konforme Rechnungsdaten zu erstellen und in PDF-Dokumente einzubetten. Doch gerade am Anfang lauern einige Stolpersteine: Composer, Pfadprobleme, Server ohne Shell-Zugriff. Dieser Beitrag zeigt, wie ich mir mit einem lokalen Setup, GitKraken und einem simplen Upload-Trick geholfen habe, um trotz aller Einschränkungen produktiv arbeiten zu können.

Bevor ich das Paket überhaupt einbinden konnte, musste Composer einmal lokal installiert werden – ganz ohne kommt man nicht aus. Ich habe mich für den Weg über die offizielle Installationsanleitung entschieden:

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php composer-setup.php
php -r "unlink('composer-setup.php');"

Es gibt aber auch fertige Pakete als *.exe für Windows.

GitKraken, Composer & das Terminal

Ich arbeite gerne visuell, und daher ist GitKraken mein bevorzugter Git-Client. Doch ein oft unterschätzter Vorteil: GitKraken bringt ein eigenes Terminal mit. Dieses habe ich genutzt, um Composer lokal zu verwenden – ohne die globale Composer-Installation auf meinem Server-System anfassen zu müssen.

Bildschirmfoto 2025-04-23 um 15.55.21.
# Im Terminal von GitKraken
composer require horstoeko/zugferd

Dabei habe ich mich bewusst für die 1.x-Version entschieden, da diese eine stabilere und besser dokumentierte Grundlage für den Einsatz ohne komplexes Setup bietet. Zudem ist dort der ZugferdDocumentPdfBuilder enthalten, der es erlaubt, das gesamte PDF-Handling im PHP-Kosmos zu belassen. Soweit ich gesehen habe, gibt es wohl auch DEV-Versionen, aber ich war mir nicht sicher wie weit diese nutzbar sind.

Der Upload-Trick: Alles lokal vorbereiten

Da mein Zielserver keinen Composer-Zugriff bietet, musste ich alles lokal vorbereiten. Ich nutze für meine Testumgebung einen einfachen Server von AllInk. Das ist extrem kostengünstig, aber eigene Software installieren, Fehlanzeige. Der Trick: Ich habe den gesamten vendor-Ordner inklusive composer.json und composer.lock gezippt und manuell auf den Server übertragen. Das spart nicht nur Zeit, sondern funktioniert in jeder Hostingumgebung.

# Lokaler Aufbau
my-project/
├── src/
├── vendor/
├── composer.json
├── composer.lock

Dann per SFTP oder FTP hochladen und sicherstellen, dass im PHP-Code folgender Autoloader korrekt eingebunden wird:

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

Vorsicht, Pfade: Die Sache mit dem “/src”-Unterordner

Ein Stolperstein war die Struktur des horstoeko-Pakets. Die Klassen liegen nicht direkt im Projektverzeichnis, sondern verstecken sich unter:

/vendor/horstoeko/zugferd/src/...

Der PSR-4-Autoloader von Composer ist darauf vorbereitet, aber wer manuell Klassen einbindet oder den Autoloader nicht korrekt referenziert, bekommt Fehler. Ein Test mit:

use horstoeko\zugferd\ZugferdDocumentPdfBuilder;

funktionierte erst, nachdem ich sicher war, dass der Autoloader geladen war und keine Pfade fehlten.

Endlich produktiv: Der erste Builder-Lauf

Nachdem alles hochgeladen und die Autoloading-Probleme beseitigt waren, konnte ich mein erstes ZUGFeRD-Dokument bauen:

$builder = new ZugferdDocumentPdfBuilder();
$builder->setDocumentFile("./rechnung.pdf");
$builder->setZugferdXml("./debug_12345.xml");
$builder->saveDocument("./zugferd_12345_final.pdf");

Und siehe da: eine ZUGFeRD-konforme PDF-Datei, direkt aus PHP erzeugt. Kein Java, kein PDF/A-Tool von Adobe, keine Blackbox. Wichtig, das ganze ist per ZIP auf jeden Kundenserver übertragbar.

Warum kein Java?

Ich habe bewusst darauf verzichtet, Java-Tools wie Apache PDFBox oder gar die offizielle ZUGFeRD Java Library zu nutzen – aus einem ganz einfachen Grund: Ich wollte die Lösung so nah wie möglich an meiner bestehenden PHP-Infrastruktur halten. Keine zusätzliche Runtime, keine komplexen Abhängigkeiten, keine Übersetzungsprobleme zwischen Systemen. PHP allein reicht – wenn man die richtigen Werkzeuge nutzt.

Häufige Fehlermeldungen und ihre Lösungen

Gerade beim Einstieg in das horstoeko/zugferd-Paket können einige typische Fehlermeldungen auftreten:

Fehler: Class 'horstoeko\zugferd\ZugferdDocumentPdfBuilder' not found

// Lösung:
require_once __DIR__ . '/vendor/autoload.php';

Fehler: Cannot open file ./debug_12345.xml

// Lösung:
Pfad prüfen! Gerade bei relativen Pfaden kann es helfen, alles absolut zu machen:
$builder->setZugferdXml(__DIR__ . '/debug_12345.xml');

Fehler: Output file cannot be written

// Lösung:
Schreibrechte auf dem Zielverzeichnis prüfen! Ein chmod 775 oder 777 (mit Bedacht!) kann helfen.

Fazit: Wer wie ich auf Servern ohne Composer arbeiten muss oder will, kann sich mit einem lokalen Setup, GitKraken und einem Zip-Upload wunderbar behelfen. Wichtig ist, auf die Pfade zu achten, den Autoloader korrekt einzubinden und nicht vor kleinen Hürden zurückzuschrecken. Die Möglichkeiten, die das horstoeko/zugferd-Paket bietet, machen die Mühe mehr als wett.

Zumal das ganze Setup, 1 zu 1, auf einen Kundenserver übertragen werden kann. Die eigentlichen Daten kommen aus FileMaker, dieser holt sich die PDF und das XML auch wieder vom Server ab. Somit ist die Erstellung der ZUGFeRD-PDF und der XML mit einen FileMaker-Script abzudecken. Für die Erstellung auf dem Server bedarf es zweier PHP-Scripte. Dazu das Horstoeko/zugferd-Paket.


ZUGFeRD mit horstoeko/zugferd und FileMaker

Bildschirmfoto 2025-04-17 um 13.55.33.

Nach unzähligen Versuchen mit TCPDF, FPDI und verschiedensten Merge-Strategien, habe ich mich letztlich für einen pragmatischeren Weg entschieden: Ich lasse sowohl das PDF als auch das XML direkt auf dem Server erzeugen – ohne nachträglichen Merge. Die Lösung basiert auf dem PHP-Paket horstoeko/zugferd, welches sich nach einigen Stolpersteinen als zuverlässig herausgestellt hat – sobald man seine Eigenheiten akzeptiert.

Im ersten Szenario habe ich die fertige PDF aus FileMaker auf den Server, dort dann die Daten als POST-Parameter empfangen. Dann versucht die beiden Dateien, XML und PDF zu verschmelzen. Keine Change, bin fast verzweifelt, kenne die Doku zu Horstoeko/Zugferd aus dem FF. Aber es hat nicht geklappt. Also jetzt der pragmatische Ansatz. Die Daten werden übertragen und dann wird auf dem Server alles erzeugt.

Der FileMaker-Teil übergibt die notwendigen Daten per POST an ein PHP-Skript auf dem Server. Dieses Skript generiert daraus die ZUGFeRD-konforme XML, erzeugt gleichzeitig ein einfaches PDF mit den wichtigsten Rechnungsdaten (z. B. Rechnungstitel, Nummer etc.) – und bindet die XML direkt beim Erzeugen ein. Kein nachträgliches Anhängen mehr nötig. Kein Merge-Objekt, kein Zwischenschritt. Die erzeugte Datei ist PDF/A-3B und enthält die eingebettete Rechnung als XML. Dabei wird die PDF gleich im PHP etwas angepasst. Vermutlich wird das ganze noch etwas schicker mit css Implementierung, aber für den Anfang reicht es so.

Hier ist der komplette PHP-Code, den ich aktuell produktiv im Testsystem einsetze:

 $_POST['sellerName'] ?? '',
		'street' => $_POST['sellerStreet'] ?? '',
		'zip' => $_POST['sellerPostalCode'] ?? '',
		'city' => $_POST['sellerCity'] ?? '',
		'country' => $_POST['sellerCountryCode'] ?? 'DE',
		'tax_id' => $_POST['sellerTaxID'] ?? ''
	];
	
	// Rechnungsempfänger-Daten
	$buyer = [
		'name' => $_POST['buyerName'] ?? '',
		'street' => $_POST['buyerStreet'] ?? '',
		'zip' => $_POST['buyerPostalCode'] ?? '',
		'city' => $_POST['buyerCity'] ?? '',
		'country' => $_POST['buyerCountryCode'] ?? 'DE',
		'tax_id' => $_POST['buyerTaxID'] ?? ''
	];
	
	// Zahlungsinformationen
	$payment = [
		'means_code' => $_POST['paymentMeansCode'] ?? '',
		'financial_institution' => $_POST['payeeFinancialInstitution'] ?? '',
		'iban' => $_POST['payeeIBAN'] ?? '',
		'bic' => $_POST['payeeBIC'] ?? '',
		'reference' => $_POST['paymentReference'] ?? ''
	];
	
	// Steuerinformationen
	$tax = [
		'rate' => floatval(str_replace(',', '.', $_POST['taxRate'] ?? '19')),
		'amount' => floatval(str_replace(',', '.', $_POST['taxAmount'] ?? '0')),
		'taxable_amount' => floatval(str_replace(',', '.', $_POST['taxableAmount'] ?? '0')),
		'category_code' => $_POST['taxCategoryCode'] ?? 'S'
	];
	
	// Positionen aus lineItemsRaw extrahieren
	$positions = [];
	$lineItems = explode('|', $_POST['lineItemsRaw'] ?? '');
	
	foreach ($lineItems as $lineItem) {
		if (empty($lineItem)) continue;
		
		$parts = explode(';', $lineItem);
		if (count($parts) >= 7) {
			$quantity = floatval($parts[3]);
			$netPrice = floatval(str_replace(',', '.', $parts[5]));
			$total = $quantity * $netPrice;
			$orderDate = isset($parts[8]) ? date('d.m.Y', strtotime($parts[8])) : '';
			
			$positions[] = [
				'position' => $parts[0],
				'description' => $parts[1],
				'article_number' => $parts[2],
				'quantity' => $quantity,
				'unit' => $parts[4],
				'net_price' => $netPrice,
				'tax_rate' => floatval($parts[6]),
				'total' => $total,
				'order_date' => $orderDate
			];
		}
	}
	
	// Summen neu berechnen
	$totalNet = 0;
	$totalTax = 0;
	
	foreach ($positions as $position) {
		$totalNet += $position['total'];
		$totalTax += $position['total'] * ($position['tax_rate'] / 100);
	}
	
	$totals = [
		'net' => $totalNet,
		'tax' => $totalTax,
		'gross' => $totalNet + $totalTax
	];
	
	// Pfade definieren
	$pdfPath = $uploadDir . $invoiceNumber . '.pdf';
	$xmlPath = $uploadDir . 'inv_' . $invoiceNumber . '.xml';
	$outputPath = $uploadDir . $invoiceNumber . '_ZUGFeRD.pdf';

	// PDF-Datei überprüfen
	if (!checkFile($pdfPath, "PDF-Datei")) {
		throw new Exception("PDF-Datei nicht verfügbar");
	}

	// ZUGFeRD-Dokument erstellen
	logMessage("Erstelle ZUGFeRD-Dokument...");
	$document = ZugferdDocumentBuilder::createNew(ZugferdProfiles::PROFILE_BASIC);
	logMessage("DocumentBuilder initialisiert");

	// Basisinformationen setzen
	logMessage("Setze Basisinformationen...");
	$document->setDocumentInformation(
		$invoiceTypeCode,                    // Dokumenttyp (380 = Rechnung)
		$invoiceNumber,                    // Rechnungsnummer
		new DateTime($invoiceDate),        // Rechnungsdatum
		$currency                          // Währung
	);
	logMessage("Basisinformationen gesetzt");

	// Rechnungssteller setzen
	logMessage("Setze Rechnungssteller...");
	$document->setDocumentSeller(
		$seller['name'],                   // Name
		$seller['zip'],                    // PLZ
		$seller['city'],                   // Stadt
		$seller['street'],                 // Straße
		$seller['country']                 // Land
	);
	logMessage("Rechnungssteller gesetzt");

	// Rechnungsempfänger setzen
	logMessage("Setze Rechnungsempfänger...");
	$document->setDocumentBuyer(
		$buyer['name'],                    // Name
		$buyer['zip'],                     // PLZ
		$buyer['city'],                    // Stadt
		$buyer['street'],                  // Straße
		$buyer['country']                  // Land
	);
	logMessage("Rechnungsempfänger gesetzt");

	// Positionen hinzufügen
	logMessage("Füge Positionen hinzu...");
	foreach ($positions as $index => $position) {
		$document->addNewPosition((string)($index + 1));
		$document->setDocumentPositionProductDetails($position['description']);
		$document->setDocumentPositionNetPrice($position['net_price']);
		$document->setDocumentPositionQuantity($position['quantity'], $position['unit']);
		$document->addDocumentPositionTax("S", "VAT", $position['tax_rate']);
		$document->setDocumentPositionLineSummation($position['total']);
		logMessage("Position {$position['description']} hinzugefügt");
	}
	
	// PDF mit Rechnungsdaten erstellen
	logMessage("🔄 Erstelle PDF mit Rechnungsdaten...");
	try {
		$pdf = new FPDF();
		$pdf->AddPage();
		$pdf->SetAutoPageBreak(true, 20);
		
		// Schriftarten definieren
		$pdf->SetFont('Arial', '', 10);
		
		// Absenderadresse
		$pdf->SetXY(20, 20);
		$pdf->Cell(0, 5, $seller['name'], 0, 1);
		$pdf->Cell(0, 5, 'GmbH & Co. KG', 0, 1);
		$pdf->Cell(0, 5, $seller['street'], 0, 1);
		$pdf->Cell(0, 5, 'D-' . $seller['zip'] . ' ' . $seller['city'], 0, 1);
		
		// Rechnungsinformationen rechts oben
		$pdf->SetXY(120, 20);
		$pdf->SetFont('Arial', '', 9);
		
		// Rechte Spalte mit Informationen
		$rightColumnData = [
			['Nummer', $invoiceNumber],
			['Datum', date('d.m.Y', strtotime($invoiceDate))],
			['Kunden Nr.', $_POST['customerNumber'] ?? '16105'],
			['Lieferschein', $_POST['deliveryNote'] ?? ''],
			['Lief. Datum', date('d.m.Y', strtotime($_POST['deliveryDate'] ?? $invoiceDate))]
		];
		
		foreach ($rightColumnData as $row) {
			$pdf->SetX(120);
			$pdf->Cell(30, 5, $row[0], 0, 0);
			$pdf->Cell(40, 5, $row[1], 0, 1);
		}
		
		// Überschrift "Rechnung"
		$pdf->SetFont('Arial', '', 14);
		$pdf->SetXY(20, 70);
		$pdf->Cell(0, 10, 'Rechnung', 0, 1);
		
		// Positionen Header
		$pdf->SetFont('Arial', '', 8);
		$pdf->SetFillColor(240, 240, 240);
		$pdf->SetY($pdf->GetY() + 5);
		
		// Spaltenbreiten
		$colWidths = [
			'auftrag' => 25,
			'bestellung' => 35,
			'kommission' => 35,
			'artikel' => 25,
			'bezeichnung' => 60,
			'menge' => 20,
			'preis' => 25,
			'gesamt' => 25
		];
		
		// Positionen ausgeben
		foreach ($positions as $index => $position) {
			// Grauer Balken für Auftragskopf
			$pdf->SetFillColor(240, 240, 240);
			$y = $pdf->GetY();
			
			// Auftragskopf mit Datum
			$pdf->Cell($colWidths['auftrag'], 5, 'Auftrag: ' . $position['article_number'] . ' / ' . $position['order_date'], 0, 0, 'L', true);
			$pdf->Cell($colWidths['bestellung'], 5, 'Ihre Bestellung: ' . $_POST['orderNumber'], 0, 0, 'L', true);
			$pdf->Cell($colWidths['kommission'], 5, 'Ihre Kommission:', 0, 1, 'L', true);
			
			// Positionsdetails
			$pdf->SetFont('Arial', '', 8);
			$pdf->Cell($colWidths['artikel'], 5, $position['article_number'], 0, 0);
			$pdf->Cell($colWidths['bezeichnung'], 5, $position['description'], 0, 0);
			$pdf->Cell($colWidths['menge'], 5, $position['quantity'] . ' ' . $position['unit'], 0, 0, 'R');
			$pdf->Cell($colWidths['preis'], 5, number_format($position['net_price'], 2, ',', '.') . ' €', 0, 0, 'R');
			$pdf->Cell($colWidths['gesamt'], 5, number_format($position['total'], 2, ',', '.') . ' €', 0, 1, 'R');
			
			$pdf->Ln(2);
		}
		
		// Summen am Ende
		$pdf->SetY(-60);
		$pdf->SetFont('Arial', '', 9);
		
		// Zahlungsbedingungen
		$pdf->Cell(0, 5, 'Rechnungsbetrag zahlbar bis ' . date('d.m.Y', strtotime($dueDate)), 0, 1);
		$pdf->Cell(0, 5, 'Bei Zahlung bis ' . date('d.m.Y', strtotime($dueDate)) . ' Skonto 3 %', 0, 1);
		
		// Summen rechtsbündig
		$pdf->SetX(-80);
		$pdf->Cell(30, 5, 'Summe netto', 0, 0, 'R');
		$pdf->Cell(50, 5, number_format($totals['net'], 2, ',', '.') . ' €', 0, 1, 'R');
		
		$pdf->SetX(-80);
		$pdf->Cell(30, 5, 'USt. ' . number_format($tax['rate'], 0) . ' %', 0, 0, 'R');
		$pdf->Cell(50, 5, number_format($totals['tax'], 2, ',', '.') . ' €', 0, 1, 'R');
		
		$pdf->SetX(-80);
		$pdf->SetFont('Arial', 'B', 9);
		$pdf->Cell(30, 5, 'Rechnungsbetrag', 0, 0, 'R');
		$pdf->Cell(50, 5, number_format($totals['gross'], 2, ',', '.') . ' €', 0, 1, 'R');
		
		// Bestellnummer
		$pdf->SetFont('Arial', '', 9);
		$pdf->SetY(-30);
		$pdf->Cell(0, 5, 'Zu Kommission:        Bestell-Nr.: ' . ($_POST['orderReference'] ?? ''), 0, 1);
		
		// Horizontale Linie am Ende
		$pdf->SetY(-20);
		$pdf->Line(20, $pdf->GetY(), 190, $pdf->GetY());
		
		$pdf->Output('F', $outputPath);
		logMessage("PDF erfolgreich generiert: $outputPath");
		
		if (file_exists($outputPath)) {
			logMessage("ZUGFeRD-PDF erfolgreich erstellt: " . basename($outputPath));
			logMessage("Dateigröße: " . filesize($outputPath) . " Bytes");
		} else {
			throw new Exception("ZUGFeRD-PDF wurde nicht erstellt!");
		}
	} catch (Exception $e) {
		logMessage(" Fehler beim PDF-Generieren: " . $e->getMessage());
		logMessage(" Stack Trace:\n" . $e->getTraceAsString());
		throw $e;
	}

} catch (Exception $e) {
	logMessage(" Fehler beim Erstellen der ZUGFeRD-PDF: " . $e->getMessage());
	logMessage(" Stack Trace:\n" . $e->getTraceAsString());
	
	// Debug-Informationen
	logMessage("\nDebug-Informationen:");
	logMessage("PHP Version: " . PHP_VERSION);
	logMessage("Memory Limit: " . ini_get('memory_limit'));
	logMessage("Max Execution Time: " . ini_get('max_execution_time'));
	logMessage("Upload Directory Permissions: " . substr(sprintf('%o', fileperms($uploadDir)), -4));
	
	// XML-Datei prüfen
	if (file_exists($xmlPath)) {
		logMessage("XML-Datei existiert: " . filesize($xmlPath) . " Bytes");
		logMessage("XML-Inhalt (erste 200 Zeichen):\n" . substr(file_get_contents($xmlPath), 0, 200));
	} else {
		logMessage("XML-Datei existiert nicht");
	}
}

Natürlich ist das so erst der Anfang, so fehlt die Überprüfung des fertigen PDF-A, aber bei händischen Tests, wurde alles akzeptiert.

Die Vorgehensweise aus FileMaker heraus ist klar. Daten sammeln und als POST übertragen. Der POST schaut in etwa so aus. Aus URL einfügen:

“-X POST " & “–header "Content-Type: application/x-www-form-urlencoded" " & “–data " & Zitat ( “invoiceNumber=” & $invoiceNumber & “&invoiceDate=” & $invoiceDate & “&invoiceCurrencyCode=” & $invoiceCurrencyCode & “&invoiceTypeCode=” & $invoiceTypeCode & “&dueDate=” & $dueDate & “&paymentTerms=” & $paymentTerms & “&deliveryTerms=” & $deliveryTerms &

“&sellerName=” & $sellerName & “&sellerStreet=” & $sellerStreet & “&sellerPostalCode=” & $sellerPostalCode & “&sellerCity=” & $sellerCity & “&sellerCountryCode=” & $sellerCountryCode & “&sellerTaxID=” & $sellerTaxID &

“&buyerName=” & $buyerName & “&buyerStreet=” & $buyerStreet & “&buyerPostalCode=” & $buyerPostalCode & “&buyerCity=” & $buyerCity & “&buyerCountryCode=” & $buyerCountryCode & “&buyerTaxID=” & $buyerTaxID &

“&paymentMeansCode=” & $paymentMeansCode & “&payeeFinancialInstitution=” & $payeeFinancialInstitution & “&payeeIBAN=” & $payeeIBAN & “&payeeBIC=” & $payeeBIC & “&paymentReference=” & $paymentReference &

“&taxRate=” & $taxRate & “&taxAmount=” & $taxAmount & “&taxableAmount=” & $taxableAmount & “&taxCategoryCode=” & $taxCategoryCode &

“&totalNetAmount=” & $totalNetAmount & “&totalTaxAmount=” & $totalTaxAmount & “&totalGrossAmount=” & $totalGrossAmount & “&lineItemsRaw=” & $lineItemsRaw

) Bildschirmfoto 2025-04-17 um 13.53.50.

Nun geht es darum, die Form der Rechnung anzupassen, die Rechnung schon auf dem System zu validieren. Im Anschluss muss diese wieder im FileMaker-System zugänglich sein. Was ich aber jetzt schon sagen kann, es geht auch ohne MBS-Plugin und ohne die dort auflaufenden Lizenz-Kosten. Der Weg bis zu diesem Punkt war wirklich sehr steinig, vieles funktionierte nicht so wie dokumentiert. Jetzt bin ich froh,


ZUGFeRD mit FileMaker, es klappt doch

Nach langer Überlegung, schaut das Ergebnis nun doch fein aus.. ZUGFeER mit FileMaker .