ZUGFeRD and XRechnung: the short version
ZUGFeRD, the B2B format. A PDF/A-3 the recipient opens like any invoice, carrying a factur-x.xml attachment their software parses. Current version 2.3, technically identical to France's Factur-X 1.0.7. Since January 2025 German businesses cannot refuse a compliant e-invoice and a plain PDF no longer counts as one; issuing becomes mandatory from January 2027 (above EUR 800k turnover) and January 2028 (everyone). Profiles EN 16931 and above satisfy the mandate; MINIMUM and BASIC WL are booking aids that do not.
XRechnung, the B2G format. Germany's national CIUS of EN 16931, maintained by KoSIT, version 3.0 in force since February 2024, required by federal portals since November 2020 with states and municipalities following. It layers the BR-DE rules on the European core, makes seller contact details and electronic addresses mandatory, and demands the Leitweg-ID in BT-10. Portals like ZRE and OZG-RE validate on acceptance, so a near-miss is a rejection.
GoBD archiving spans both. Invoices must be archived as received, immutable and machine-evaluable. ZUGFeRD conveniently archives as one self-contained file; XRechnung archives as XML, which is why rendering a human-readable preview appears later in this guide.
The machinery underneath (roughly 200 EN 16931 business rules, German overlays, PDF/A-3 conformance with exact XMP metadata, Schematron pipelines) is what the technical overview of ZUGFeRD automation unpacks layer by layer.
One payload, two endpoints
| Your customer | Format | Deliverable | Endpoint |
| German business (B2B) | ZUGFeRD EN 16931 | Hybrid PDF/A-3 | /v1/create/zugferd |
| Federal or state authority (B2G) | XRechnung 3.0 | Pure XML + Leitweg-ID | /v1/create/xrechnung |
| French business | Factur-X | Hybrid PDF/A-3 | /v1/create/facturx |
| Peppol network recipient | Peppol BIS 3.0 | UBL XML | /v1/create/ubl |
All four rows consume the same invoice array, so multi-format support in PHP is a ternary on the endpoint, not parallel integrations. This guide covers the German rows; Factur-X and Peppol UBL each have their own PHP guide.
The PHP library landscape
PHP is better served than most ecosystems here, and pretending otherwise would be unfair:
horstoeko/zugferd is the standout: an actively maintained library with a fluent builder for ZUGFeRD 2.x and Factur-X documents across the profile ladder, XRechnung CII generation included, plus readers for existing hybrids. It is the strongest open-source option outside Java's Mustangproject, which is exactly why its boundaries are the part worth understanding.
The supporting cast includes atgp/factur-x (a PHP take on the Akretion embedding tool, joining a PDF and a finished XML into a hybrid) and the older easybill/zugferd-php generation of libraries that horstoeko has largely superseded.
Now the boundaries, which are structural:
Official validation is out of reach in-process. The EN 16931 and KoSIT rule sets compile to XSLT 2.0; PHP's ext-xsl wraps libxslt and stops at XSLT 1.0. Library-level checks are structural (schema and completeness), not the 200+ business rules a recipient or portal enforces. Teams that want the official verdict locally end up shelling out to Java tooling, which is a JVM dependency wearing a PHP costume.
PDF/A-3 conformance of your visual layer is on you. The libraries embed XML into the PDF you provide; making that PDF genuinely PDF/A-3 conformant (embedded fonts, declared color spaces, correct XMP) is a separate, unsolved problem in PHP, and failures surface as rejections at the receiving end.
KoSIT's calendar becomes your calendar. A new XRechnung version ships yearly with Schematron patches in between; every release is a migration you own.
Plain supplier PDFs are out of scope. No library reads an unstructured document into invoice data. That is document understanding, not XML assembly.
The sections below run the whole lifecycle over the API instead, and the closing comparison weighs that maintenance load against a complete compliance service.
Client setup with Guzzle
One Guzzle client covers every call. Setting http_errors to false matters: validation problems arrive as HTTP 400 with structured findings in the body, and you want to read them, not catch an exception that hides them.
Create a free InvoiceXML account to get an API key with 100 free credits, no credit card needed. Load the key from the environment, never from source:
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://api.invoicexml.com',
'headers' => ['Authorization' => 'Bearer ' . getenv('INVOICEXML_API_KEY')],
'timeout' => 120,
'http_errors' => false,
]);
Create ZUGFeRD invoices in PHP
The payload is a plain array. Net prices, quantities, and rates go in; totals and the per-rate VAT breakdown are computed server-side, and the document is validated against the full EN 16931 rule set before it comes back. The sample mixes the 19% standard and 7% reduced German rates:
$invoice = [
'invoice' => [
'invoiceNumber' => 'RE-2026-3310',
'issueDate' => '2026-07-28',
'currency' => 'EUR',
'seller' => [
'name' => 'Neckarblick IT GmbH',
'vatIdentifier' => 'DE315498762',
'postalAddress' => [
'line1' => 'Königstraße 82',
'city' => 'Stuttgart',
'postCode' => '70173',
'country' => 'DE',
],
],
'buyer' => [
'name' => 'Ostsee Handels GmbH',
'postalAddress' => [
'line1' => 'Lange Straße 19',
'city' => 'Rostock',
'postCode' => '18055',
'country' => 'DE',
],
],
'paymentDetails' => [
'paymentAccountIdentifier' => 'DE75512108001245126199',
],
'lines' => [
[
'quantity' => 25,
'item' => ['name' => 'Wartungsvertrag Warenwirtschaft, Juli 2026'],
'priceDetails' => ['netPrice' => 68.00],
'vatInformation' => ['rate' => 19],
],
[
'quantity' => 40,
'item' => ['name' => 'Bedienhandbuch (gedruckt)'],
'priceDetails' => ['netPrice' => 14.50],
'vatInformation' => ['rate' => 7],
],
],
],
];
$response = $client->post('/v1/create/zugferd', ['json' => $invoice]);
if ($response->getStatusCode() !== 200) {
throw new RuntimeException((string) $response->getBody());
}
file_put_contents('rechnung-zugferd.pdf', (string) $response->getBody());
The response is a finished ZUGFeRD 2.3 document: PDF/A-3 container, embedded factur-x.xml, XMP metadata declaring the EN 16931 profile, checked against the official Schematron. Data that cannot produce a compliant invoice is refused with HTTP 400 and the violated rules as structured findings, so nothing broken ever ships to a customer.
Runnable PHP examples on GitHub →
Validate ZUGFeRD invoices in PHP
Validate outgoing files before dispatch and supplier hybrids on arrival. The endpoint extracts the embedded XML, detects the declared profile from BT-24, and runs that profile's official XSD plus Schematron rules. Guzzle's multipart option handles the upload:
function validateZugferd(Client $client, string $path): array
{
$response = $client->post('/v1/validate/zugferd', [
'multipart' => [
[
'name' => 'file',
'contents' => fopen($path, 'r'),
'filename' => basename($path),
],
],
]);
return json_decode((string) $response->getBody(), true);
}
$report = validateZugferd($client, 'rechnung-zugferd.pdf');
if ($report['valid']) {
echo "Gültiges ZUGFeRD, Profil {$report['data']['profile']}\n";
} else {
foreach ($report['errors'] as $finding) {
echo "[{$finding['rule']}] {$finding['message']}\n";
echo ' Felder: ' . implode(', ', $finding['fields'] ?? []) . "\n";
}
}
A completed validation always answers HTTP 200; the verdict is the valid flag, and non-2xx statuses mean transport problems such as a bad key or an unreadable upload. Findings carry rule ids, readable messages, business term codes, and field paths, ready for a UI or a supplier email without editing.
Create XRechnung invoices in PHP
Same array shape, different endpoint, and the two additions XRechnung enforces on top of base EN 16931:
buyerReference carries the Leitweg-ID. Mapped to BT-10 and enforced by rule BR-DE-15. Your public sector buyer assigns it during onboarding; it is routing infrastructure, never invoice data, so it lives in your buyer records and gets injected into every request.
Seller contact and electronic addresses are mandatory. The full seller.contact group (name, phone, email) plus electronic addresses for both parties.
$xrechnung = [
'invoice' => [
'invoiceNumber' => 'RE-2026-3311',
'issueDate' => '2026-07-28',
'dueDate' => '2026-08-27',
'currency' => 'EUR',
'buyerReference' => '08111-00234-45',
'seller' => [
'name' => 'Neckarblick IT GmbH',
'vatIdentifier' => 'DE315498762',
'postalAddress' => [
'line1' => 'Königstraße 82',
'city' => 'Stuttgart',
'postCode' => '70173',
'country' => 'DE',
],
'contact' => [
'name' => 'Miriam Vogel',
'phone' => '+49 711 2203980',
'email' => '[email protected]',
],
'electronicAddress' => ['identifier' => 'DE315498762', 'schemeId' => '0204'],
],
'buyer' => [
'name' => 'Landeshauptstadt Stuttgart, Schulverwaltungsamt',
'postalAddress' => [
'line1' => 'Hauptstätter Straße 79',
'city' => 'Stuttgart',
'postCode' => '70178',
'country' => 'DE',
],
'electronicAddress' => ['identifier' => '08111-00234-45', 'schemeId' => '0204'],
],
'paymentDetails' => [
'paymentMeansCode' => 58,
'paymentAccountIdentifier' => 'DE75512108001245126199',
],
'lines' => [
[
'quantity' => 210,
'unitCode' => 'HUR',
'item' => ['name' => 'Betreuung Schul-IT, Juli 2026'],
'priceDetails' => ['netPrice' => 92.00],
'vatInformation' => ['rate' => 19, 'categoryCode' => 'S'],
],
],
],
];
$response = $client->post('/v1/create/xrechnung', ['json' => $xrechnung]);
if ($response->getStatusCode() !== 200) {
throw new RuntimeException((string) $response->getBody());
}
file_put_contents('rechnung-xrechnung.xml', (string) $response->getBody());
The response is XRechnung 3.0 in CII syntax, already validated against the EN 16931 Schematron and the KoSIT rules. If your stack also serves the Peppol network, the UBL binding may fit better: call POST /v1/create/ubl with 'options' => ['profile' => 'xrechnung'] and the same invoice array. Both outputs are legally equivalent XRechnung.
Validate XRechnung invoices in PHP
POST /v1/validate/xrechnung auto-detects CII or UBL syntax and runs three layers: the XSD, the EN 16931 core Schematron, and the German BR-DE rules from the KoSIT Schematron, the same stack ZRE and OZG-RE apply on submission. It accepts XML from any producer, horstoeko output included:
function validateXrechnung(Client $client, string $path): array
{
$response = $client->post('/v1/validate/xrechnung', [
'multipart' => [
[
'name' => 'file',
'contents' => fopen($path, 'r'),
'filename' => basename($path),
],
],
]);
return json_decode((string) $response->getBody(), true);
}
$report = validateXrechnung($client, 'rechnung-xrechnung.xml');
if (!$report['valid']) {
foreach ($report['errors'] as $finding) {
// $finding['layer'] is 'xsd', 'en16931', or 'cius' (the BR-DE rules)
echo "[{$finding['rule']}] ({$finding['layer']}) {$finding['message']}\n";
}
}
Show the layer tag to users: a cius finding means the document is a perfectly valid EN 16931 invoice that specifically misses a German requirement, with BR-DE-15 on a missing Leitweg-ID as the classic. This endpoint takes XML only; a ZUGFeRD hybrid declaring the XRechnung reference profile goes to /v1/validate/zugferd, and its embedded XML is routed to the same KoSIT rules automatically.
Read incoming invoices: extraction plus AI
Reception has been mandatory for every German business since 2025, and the inbox that obligation creates is mixed: ZUGFeRD hybrids, XRechnung XML, and plain PDFs from suppliers who never modernised. One intake function covers all of it: deterministic extraction first, AI fallback only when the API reports error code 4006 (no embedded XML):
function readIncomingInvoice(Client $client, string $path): array
{
$upload = fn (): array => ['multipart' => [
[
'name' => 'file',
'contents' => fopen($path, 'r'),
'filename' => basename($path),
],
]];
$response = $client->post('/v1/extract/json', $upload());
if ($response->getStatusCode() === 200) {
return [
'source' => 'embedded-xml',
'invoice' => json_decode((string) $response->getBody(), true),
];
}
$error = json_decode((string) $response->getBody(), true);
if (($error['errorCode'] ?? null) !== 4006) {
throw new RuntimeException((string) $response->getBody());
}
// No embedded XML: an old-school PDF, run the AI parser
$response = $client->post('/v1/parse/json', $upload());
if ($response->getStatusCode() !== 200) {
throw new RuntimeException((string) $response->getBody());
}
return ['source' => 'ai'] + json_decode((string) $response->getBody(), true);
}
The two branches are different in kind. Extraction is exact XML parsing: what the supplier declared is what you import. The AI branch reads the document visually (OCR included, scans and photos welcome) and therefore returns a confidence object next to the invoice: an overall score and four area scores (seller, buyer, tax calculation, line items) between 0.0 and 1.0. Gate your automation on it:
$result = readIncomingInvoice($client, 'eingang/lieferant-scan.pdf');
if ($result['source'] === 'ai' && $result['confidence']['overall'] < 0.7) {
queueForReview($result['invoice'], $result['confidence']);
} else {
importIntoErp($result['invoice']);
}
Non-invoices fail the AI branch with error code 4008 and multi-invoice files with 4009. The third intake endpoint serves a German B2G habit: supporting documents (delivery notes, timesheets, order copies) travel base64-embedded inside the invoice XML in the BG-24 group, and POST /v1/extract/attachments decodes them into a ZIP, answering 404 when the invoice carries none:
function extractAttachments(Client $client, string $path, string $zipPath): ?string
{
$response = $client->post('/v1/extract/attachments', [
'multipart' => [
[
'name' => 'file',
'contents' => fopen($path, 'r'),
'filename' => basename($path),
],
],
]);
if ($response->getStatusCode() === 404) {
return null; // invoice carries no embedded attachments
}
if ($response->getStatusCode() !== 200) {
throw new RuntimeException((string) $response->getBody());
}
file_put_contents($zipPath, (string) $response->getBody());
return $zipPath;
}
extractAttachments($client, 'rechnung-xrechnung.xml', 'belege.zip');
Prefer the raw embedded CII over parsed JSON? /v1/extract/xml streams the XML out of a hybrid PDF untouched, ready for horstoeko/zugferd's reader or plain SimpleXML.
Render XRechnung previews
XRechnung has nothing to look at, which becomes a real workflow gap the moment someone in accounting wants to check an invoice before it goes to a portal. POST /v1/render/xrechnung/to/pdf lays every business term out on a clean, readable PDF, Leitweg-ID labelled in the buyer reference section, with both syntaxes detected automatically:
function renderXrechnung(Client $client, string $xmlPath, string $pdfPath): void
{
$response = $client->post('/v1/render/xrechnung/to/pdf', [
'multipart' => [
[
'name' => 'file',
'contents' => fopen($xmlPath, 'r'),
'filename' => basename($xmlPath),
],
],
]);
file_put_contents($pdfPath, (string) $response->getBody());
}
renderXrechnung($client, 'rechnung-xrechnung.xml', 'rechnung-vorschau.pdf');
The preview carries no legal weight; the XML remains the invoice, and only the XML is submitted.
Laravel integration
In Laravel, the Http facade replaces the raw Guzzle client and the B2B/B2G split collapses into one service with a boolean:
// config/services.php
'invoicexml' => [
'base_url' => env('INVOICEXML_BASE_URL', 'https://api.invoicexml.com'),
'api_key' => env('INVOICEXML_API_KEY'),
],
// app/Services/GermanInvoiceService.php
namespace App\Services;
use App\Exceptions\InvoiceComplianceException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class GermanInvoiceService
{
/** @return array{0: string, 1: string} [$content, $extension] */
public function create(array $payload, bool $publicSector): array
{
$endpoint = $publicSector ? '/v1/create/xrechnung' : '/v1/create/zugferd';
$response = $this->http()->post($endpoint, ['invoice' => $payload]);
if ($response->failed()) {
throw new InvoiceComplianceException($response->body());
}
return [$response->body(), $publicSector ? 'xml' : 'pdf'];
}
public function validate(string $content, bool $publicSector): array
{
$endpoint = $publicSector ? '/v1/validate/xrechnung' : '/v1/validate/zugferd';
$filename = $publicSector ? 'invoice.xml' : 'invoice.pdf';
return $this->http()
->attach('file', $content, $filename)
->post($endpoint)
->json();
}
private function http(): PendingRequest
{
return Http::baseUrl(config('services.invoicexml.base_url'))
->withToken(config('services.invoicexml.api_key'))
->timeout(120);
}
}
Generation belongs in a queued job. The retry discipline is the part worth getting right: transient transport failures deserve retries, HTTP 400 does not, because it carries findings about your data and identical input reproduces identical findings. Failing fast on InvoiceComplianceException keeps the queue honest:
// app/Jobs/IssueInvoice.php
namespace App\Jobs;
use App\Exceptions\InvoiceComplianceException;
use App\Models\Invoice;
use App\Services\GermanInvoiceService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Storage;
class IssueInvoice implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = 30;
public function __construct(private readonly int $invoiceId)
{
}
public function handle(GermanInvoiceService $service): void
{
$invoice = Invoice::findOrFail($this->invoiceId);
try {
[$content, $extension] = $service->create(
$invoice->toApiPayload(),
publicSector: $invoice->buyer->is_public_sector,
);
} catch (InvoiceComplianceException $e) {
$this->fail($e); // data problem: do not retry, surface it
return;
}
Storage::put("invoices/{$invoice->number}.{$extension}", $content);
}
}
For public sector buyers, validate the Leitweg-ID's presence at the model level so a missing one never reaches the queue. And pin the whole payload mapping in CI with a Pest test per format:
it('produces valid ZUGFeRD from our billing data', function () {
$service = new GermanInvoiceService();
[$content] = $service->create(sampleB2bPayload(), publicSector: false);
expect($service->validate($content, publicSector: false)['valid'])->toBeTrue();
});
it('produces XRechnung that passes the KoSIT rules', function () {
$service = new GermanInvoiceService();
[$content] = $service->create(sampleB2gPayload(), publicSector: true);
expect($service->validate($content, publicSector: true)['valid'])->toBeTrue();
});
PHP library vs REST API
The fair split, given that PHP has a real library:
The library route is a permanent engineering commitment. horstoeko/zugferd is free and well designed, and building on it still means owning everything around the XML: the PDF/A-3 conformance of your visual layer, validation you cannot run in-process (structural checks are not the 200+ business rules, and the gap shows when a recipient rejects a file), previews, intake, and the KoSIT calendar. Every specification release means watching the announcement, waiting for the package update, bumping composer.json, retesting, and redeploying, and German e-invoicing releases every year.
The API is a complete compliance service. Creation, validation, extraction, AI parsing, and rendering behind one integration, with the official EN 16931 and KoSIT rule sets live server-side before their effective dates. New ZUGFeRD and XRechnung versions ship out of the box: nothing to monitor, no package to wait for, no redeploy. Your composer.json carries at most Guzzle, PDF/A-3 conformance is not your problem, the domain expertise sits on our side with professional support behind it, missing features get integrated on request, and the same integration extends to Factur-X and Peppol UBL by swapping the endpoint.
The deciding question is who owns the moving target. Compliance changes on a government calendar, not yours. Build on a library and that calendar dictates your releases indefinitely; build on the API and your integration is finished the day it works, because keeping it compliant is our job, not a recurring item on your backlog.
Data handling is stateless by architecture: documents are processed in memory and purged when the response ships. Nothing is stored, nothing is logged, and no invoice data trains any model. VAT numbers, IBANs, and Leitweg-IDs exist server-side for one HTTP exchange and no longer.
Endpoint reference
| Operation | Endpoint | Input | Output |
| Create ZUGFeRD | POST /v1/create/zugferd | JSON | PDF/A-3 binary |
| Create XRechnung (CII) | POST /v1/create/xrechnung | JSON | XRechnung 3.0 XML |
| Create XRechnung (UBL) | POST /v1/create/ubl + options.profile: xrechnung | JSON | XRechnung UBL XML |
| Validate ZUGFeRD | POST /v1/validate/zugferd | ZUGFeRD PDF | Validation JSON |
| Validate XRechnung | POST /v1/validate/xrechnung | XRechnung XML (CII or UBL) | Validation JSON |
| Extract as JSON | POST /v1/extract/json | ZUGFeRD PDF or XRechnung XML | Structured JSON |
| Parse PDF with AI | POST /v1/parse/json | Any invoice PDF (typed, scanned, photo) | Structured JSON + confidence |
| Extract attachments | POST /v1/extract/attachments | ZUGFeRD PDF or XRechnung XML | ZIP archive |
| Render XRechnung as PDF | POST /v1/render/xrechnung/to/pdf | XRechnung XML | PDF binary |
| Extract CII XML | POST /v1/extract/xml | ZUGFeRD PDF | CII XML |
Full OpenAPI 3.1 schema: api.invoicexml.com/v1/openapi | Interactive API explorer: api.invoicexml.com/v1/scalar
Get started
Everything in this guide needs composer require guzzlehttp/guzzle and nothing else: set INVOICEXML_API_KEY and run. The runnable versions live in the examples repository:
InvoiceXML/facturx-api-examples/php →
Create a free InvoiceXML account → get 100 credits for free, no credit card required.
Related resources:
InvoiceXML is a REST API for European e-invoice compliance covering ZUGFeRD, XRechnung, Factur-X, Peppol UBL, and CII. Stateless processing, GDPR compliant by architecture, and callable from any PHP application: Laravel, Symfony, WordPress plugins, or a plain script.