Generate Factur-X and ZUGFeRD invoices in PHP: the 2026 guide
How to produce EN 16931-compliant Factur-X / ZUGFeRD hybrid invoices in plain PHP — the document model, the horstoeko/zugferd mapping, PDF/A-3 embedding, and the business rules that actually reject documents — from an engine that passes the official KoSIT Schematron in CI.
Search for "factur-x php" today and you get a wall of GitHub repos and Packagist pages — libraries, no guide. Which is a problem, because the deadlines are real: in France, every business must be able to receive e-invoices on 1 September 2026, with large and mid-size companies required to issue them from the same date and SMEs following on 1 September 2027 (checked 6 July 2026 against the official guidance). Germany has required all B2B companies to accept structured e-invoices since January 2025, with mandatory issuance phasing in through 2027–2028.
Both mandates converge on the same technical object: an invoice that is simultaneously a PDF a human can read and an XML document a machine can process. In France it's called Factur-X; in Germany, ZUGFeRD 2.x. They are the same standard.
We built an e-invoicing engine in PHP for a production invoicing product — every document it emits is validated against the official French/German (KoSIT) and PEPPOL rule sets in CI. This post is the guide we couldn't find: the model, the library, the mapping, the embedding, and the traps.
The 30-second standard primer
Three names, one layer cake:
- EN 16931 is the European semantic standard: the list of fields an invoice must and may carry (they're numbered — "BT-1" is the invoice number, "BT-9" the due date), plus arithmetic and consistency rules.
- CII (UN/CEFACT Cross Industry Invoice) is one of the two XML syntaxes that can carry those semantics. The other is UBL.
- Factur-X / ZUGFeRD is a hybrid format: a PDF/A-3 file with the
CII XML embedded as an attachment named
factur-x.xml(orzugferd-invoice.xml). Humans read the page, machines read the XML — that's the whole point.
Factur-X comes in profiles of increasing richness: MINIMUM, BASIC WL, BASIC, EN 16931 (also called COMFORT), and EXTENDED. Aim for the EN 16931 profile — it's the one that carries the full semantic model and the one the French and German mandates are built around. The MINIMUM profile is not a valid invoice in either country; it's a processing aid.
One library does the XML plumbing
You do not want to hand-write CII. The element order alone (enforced by
XSD) will eat a week of your life. The PHP ecosystem has settled on
horstoeko/zugferd — we pin
v1.0.123 (checked 6 July 2026), it's actively maintained and covers
building, reading and PDF embedding:
composer require horstoeko/zugferd
One architectural rule made everything that follows easier: exactly one class in our codebase knows this library exists. The rest of the engine works on our own immutable document model, and a single serializer maps it onto the builder. When the library changes its API (or we outgrow it), the blast radius is one file.
Model money as integers, convert at the boundary
Before touching the library, get the model right. Our engine's value
objects — EInvoiceDocument, Party, DocumentLine, VatBreakdown,
MonetaryTotals — carry every amount as integer minor units (cents).
Only the serialization boundary produces a decimal representation:
final class Money
{
/** Integer cents → a fixed 2-decimal string ("1990" → "19.90"). */
public static function decimals(int $cents): string
{
$sign = $cents < 0 ? '-' : '';
$abs = abs($cents);
return \sprintf('%s%d.%02d', $sign, intdiv($abs, 100), $abs % 100);
}
/** Expected VAT in cents, rounded half-up — what EN 16931 BR-CO-17 checks. */
public static function taxOf(int $taxableCents, float $ratePercent): int
{
return (int) round($taxableCents * $ratePercent / 100.0);
}
}
Why so strict? EN 16931 ships arithmetic rules (the BR-CO-* family): line
amounts must sum to the line-extension total, VAT per category must equal
the taxable base times the rate rounded to the cent, totals must reconcile
to the payable amount. Validators recompute these. If your amounts round
through floats anywhere — 0.29 becoming 0.28999… — you will emit
documents that fail Schematron validation on someone else's machine and not
yours. Integers sum exactly; convert once, at the edge.
Map your document onto the CII builder
The serializer is mechanical once the model is clean. Condensed from our production mapping:
use horstoeko\zugferd\ZugferdDocumentBuilder;
use horstoeko\zugferd\ZugferdProfiles;
$builder = ZugferdDocumentBuilder::createNew(ZugferdProfiles::PROFILE_EN16931);
// BT-1, BT-3 (380 = invoice, 381 = credit note), BT-2, BT-5
$builder->setDocumentInformation('FA-2026-0042', '380', $issueDate, 'EUR');
$builder->setDocumentBuyerReference($buyerReference); // BT-10
// Parties: name, VAT registration, postal address
$builder->setDocumentSeller($seller->name);
$builder->addDocumentSellerTaxRegistration('VA', $seller->vatNumber);
$builder->setDocumentSellerAddress($line1, $line2, $line3, $postcode, $city, 'FR');
// …same shape for the buyer…
// One position per line — quantity unit 'C62' is UN/ECE for "piece"
$builder->addNewPosition('1');
$builder->setDocumentPositionProductDetails('Pro plan — July 2026');
$builder->setDocumentPositionNetPrice(19.90);
$builder->setDocumentPositionQuantity(1.0, 'C62');
$builder->addDocumentPositionTax('S', 'VAT', 20.0);
$builder->setDocumentPositionLineSummation(19.90);
// The VAT breakdown (BG-23) and document totals (BG-22)
$builder->addDocumentTax('S', 'VAT', 19.90, 3.98, 20.0);
$builder->setDocumentSummation(23.88, 23.88, 19.90, 0.0, 0.0, 19.90, 3.98, 0.0, 0.0);
$xml = $builder->getContent(); // the factur-x.xml payload
The library does the XML plumbing; the semantic mapping is the actual work, and it's domain knowledge, not code volume:
- VAT categories are an enum, not a rate.
Sis standard-rated;Zis zero-rated;Eexempt;AEreverse charge;Kintra-community supply;Gexport. Each category triggers its own rule set — anE,AEorKbreakdown must carry an exemption reason text or code, andAE/Krequire both parties' VAT numbers. - Intra-community supplies need delivery facts. Rules BR-IC-11 and
BR-IC-12: an invoice with a
Kbreakdown must state the actual delivery date (BT-72) and the deliver-to country (BT-80). Nobody tells you this until a validator rejects the document. - Legal registration schemes are national. A French seller's SIREN goes
into the legal-organisation ID with ISO 6523 scheme
0002; other countries use other schemes or none. - Credit notes reference their invoice. Type code
381, plus the preceding invoice number and date — and their totals are positive amounts with the semantics of a credit, not negated invoices.
Expect the mapping table — your fields to BT-terms — to be the artifact you maintain, and keep it in one class.
Embed the XML into the PDF
A Factur-X file is your rendered PDF upgraded to PDF/A-3 with the XML attached. With the same library, the embedder is genuinely this small:
use horstoeko\zugferd\ZugferdDocumentPdfBuilder;
$pdfBuilder = new ZugferdDocumentPdfBuilder($builder, $pdfBytes);
$pdfBuilder->generateDocument();
$hybridPdfBytes = $pdfBuilder->downloadString();
$pdfBytes is whatever your existing pipeline renders (ours comes out of
dompdf). The visible document is untouched; the output is a PDF/A-3 with
factur-x.xml embedded and the XMP metadata that declares the profile.
Recipients who want the page print the page; platforms that want data
extract the XML. You ship one file.
Valid XML is not a valid invoice
The trap that catches everyone: your document can pass the CII XSD and
still be an invalid invoice. The XSD checks structure. The business rules —
the BR-* arithmetic, the VAT-category logic, the country-specific
requirements — live in Schematron, a second validation layer, and
that's where real-world rejections happen.
Our approach, in two tiers:
- An always-on PHP gate. A dependency-free validator that re-implements
the EN 16931 rules our documents can realistically violate (content,
BR-CO-*arithmetic, VAT-category rules). It runs in unit tests and before every document leaves the app — instant feedback, no Java. - The official artefacts in CI. The KoSIT validator (the German reference implementation, which validates EN 16931 CII) and the OpenPEPPOL Schematron run as blocking CI jobs over reference documents generated by the real engine. They are the referee; the PHP gate is the linter.
The referee earns its keep: it caught real conformance gaps in our engine — a missing due date falling foul of BR-CO-25, the intra-community delivery rules above — before any customer or tax platform did. The full CI setup (pinned validator versions, Schematron compilation, SVRL parsing) is its own post: Validate EN 16931 e-invoices in CI: KoSIT + OpenPEPPOL.
What about UBL and PEPPOL?
Factur-X solves the French/German hybrid-document story. If your buyers sit on the PEPPOL network (the default rail in the Nordics, the Benelux, and increasingly everywhere), they'll want UBL — the other EN 16931 syntax — conforming to the PEPPOL BIS Billing 3.0 profile, which adds its own rules on top (electronic addresses for both parties, a mandatory buyer reference…). Same semantic model, second serializer. Design your document model against EN 16931's terms rather than against CII and the second syntax becomes a mapping exercise instead of a rewrite.
Ship it
The recipe, compressed: model the invoice as immutable values with integer
cents · pick the EN 16931 profile · let horstoeko/zugferd build the CII
and embed it into PDF/A-3 · keep the library behind one boundary class ·
validate with a fast in-code gate locally and the official Schematron in
CI. None of it is hard once you know where the cliffs are — it's just
unforgiving of guesswork.
The engine described here runs in production behind a SaaS. If you're building one of those in Symfony and would rather start past the plumbing — auth with 2FA, teams, billing on Stripe and Lemon Squeezy, an admin, a test suite holding it all down — that's what ShipAnvil ships. Poke at the live demo to see the foundations this kind of engine plugs into.