Automation MCP Server Features Blog Pricing Contact
Integration ZUGFeRD Format

Create and Validate ZUGFeRD and XRechnung Invoices in PHP: Complete Guide

Both German e-invoice formats behind one PHP integration: ZUGFeRD hybrid PDFs for B2B customers and XRechnung pure XML for the public sector. Creating from an array, validating against EN 16931 and the KoSIT rules, reading incoming invoices with deterministic extraction plus AI parsing, and rendering previews, with Guzzle throughout, Laravel queue patterns, and an honest look at horstoeko/zugferd.

PHP powers a huge share of the invoicing software German businesses actually run, and the mandate hands every one of those systems the same two-format problem. B2B customers expect ZUGFeRD, a hybrid PDF/A-3 with embedded CII XML, reception of which became compulsory across Germany in January 2025 with issuance phasing in from 2027. Public sector buyers require XRechnung instead: pure XML, no visual layer, submitted to portals that enforce the KoSIT rules and bounce any document missing its Leitweg-ID. Same invoice data, two very different deliverables.

This guide handles both from one integration, because under the branding they share the EN 16931 semantic model and the API accepts an identical invoice array for either. The difference between a B2B and a B2G invoice in the code below comes down to the endpoint and two extra fields. Everything runs on Guzzle, a Laravel section covers the production shape with the Http facade and queued jobs, and runnable counterparts live in the PHP examples repository. Invoicing French customers instead? The same hybrid format is branded Factur-X there, covered in the companion guide to Factur-X in PHP.

PHP also deserves credit before the pitch: horstoeko/zugferd is a genuinely capable open-source library for these formats, and the landscape section is honest about what it covers and where the compliance wall stands: official validation, PDF/A conformance, and unstructured input, the parts the ZUGFeRD API and XRechnung API keep server-side.

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 customerFormatDeliverableEndpoint
German business (B2B)ZUGFeRD EN 16931Hybrid PDF/A-3/v1/create/zugferd
Federal or state authority (B2G)XRechnung 3.0Pure XML + Leitweg-ID/v1/create/xrechnung
French businessFactur-XHybrid PDF/A-3/v1/create/facturx
Peppol network recipientPeppol BIS 3.0UBL 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

OperationEndpointInputOutput
Create ZUGFeRDPOST /v1/create/zugferdJSONPDF/A-3 binary
Create XRechnung (CII)POST /v1/create/xrechnungJSONXRechnung 3.0 XML
Create XRechnung (UBL)POST /v1/create/ubl + options.profile: xrechnungJSONXRechnung UBL XML
Validate ZUGFeRDPOST /v1/validate/zugferdZUGFeRD PDFValidation JSON
Validate XRechnungPOST /v1/validate/xrechnungXRechnung XML (CII or UBL)Validation JSON
Extract as JSONPOST /v1/extract/jsonZUGFeRD PDF or XRechnung XMLStructured JSON
Parse PDF with AIPOST /v1/parse/jsonAny invoice PDF (typed, scanned, photo)Structured JSON + confidence
Extract attachmentsPOST /v1/extract/attachmentsZUGFeRD PDF or XRechnung XMLZIP archive
Render XRechnung as PDFPOST /v1/render/xrechnung/to/pdfXRechnung XMLPDF binary
Extract CII XMLPOST /v1/extract/xmlZUGFeRD PDFCII 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.

Start free today

Ready to automate your invoices?

Validate, convert and embed compliant e-invoices through one API. Start your 30-day free trial. No credit card required.

GDPR Compliant No credit card required Setup in minutes
Peppol UBL
Factur-X
EN 16931
142 / 142 passed
Compliant
PDF/A-3 embedded