horstoeko/zugferd
A PHP library for creating, reading, and validating ZUGFeRD and Factur-X electronic invoices, including XML data and PDF/A-3 hybrid invoice documents for compliant e-invoicing workflows.
Install with Composer (PHP 8.1+ is required by current releases):
composer require horstoeko/zugferd
First, read ZugferdProfiles (the profile enum-ish constants: PROFILE_MINIMUM, PROFILE_BASIC, PROFILE_EN16931, PROFILE_EXTENDED, PROFILE_XRECHNUNG_3, …). Everything else hangs off four classes:
ZugferdDocumentBuilder — creates XMLZugferdDocumentReader — parses XML or PDF (it extracts the embedded XML for you)ZugferdDocumentPdfBuilder — attaches the XML to a PDF/A-3 fileZugferdDocumentValidator — runs the EN16931/XRechnung rules (shells out to Java)First use case: build an EN16931 (Factur-X COMFORT) invoice from an Eloquent Order.
use horstoeko\zugferd\ZugferdDocumentBuilder;
use horstoeko\zugferd\ZugferdProfiles;
$doc = ZugferdDocumentBuilder::createNew(ZugferdProfiles::PROFILE_EN16931);
$doc->setDocumentInformation(
'RE-2024-0001', '380', new \DateTimeImmutable('2024-05-01'), 'EUR'
)
->setDocumentSeller('Acme GmbH', 'HRB 12345')
->addDocumentSellerTaxRegistration('VA', 'DE123456789')
->setDocumentBuyer('Customer AG', 'K-42')
->setDocumentBuyerReference('04011000-12345-67');
$doc->addDocumentTax('S', 'VAT', 19.0, 100.00, 19.00);
$doc->addNewPosition('1')
->setDocumentPositionProductDetails('Widget', 'A widget', 'WID-1')
->setDocumentPositionNetPrice(100.00)
->setDocumentPositionQuantity(1, 'H87')
->addDocumentPositionTax('S', 'VAT', 19.0)
->setDocumentPositionLineSummation(100.00);
$doc->setDocumentSummation(119.00, 119.00, 100.00, 0.00, 0.00, 100.00, 19.00);
$xml = $doc->getContent(); // string
// or $doc->writeFile($path);
For a PDF, generate the visual invoice with whatever you already use (DomPDF, Browsershot, Snappy), then embed:
use horstoeko\zugferd\ZugferdDocumentPdfBuilder;
ZugferdDocumentPdfBuilder::fromPdfFile($doc, $sourcePdfPath)
->generateDocument()
->saveDocument($outputPdfPath);
The critical first lesson: the library does not make an arbitrary PDF PDF/A-3 compliant. It attaches the XML and sets the AF relationship/metadata; the input PDF must already be PDF/A-3 (embedded fonts, correct color space, XMP). Budget time for that separately.
Wrap it in a service, not in controllers. The builder is a mutable, chained object and profile rules differ per tenant. A single InvoiceXmlFactory / ZugferdInvoiceService that takes a DTO (or a well-scoped Eloquent model) and returns {xml, profile, hash} keeps the static-factory API out of your domain code and makes it testable.
Map to DTOs first. Build an InvoiceData value object (seller, buyer, lines, taxes, totals) once, then have a thin adapter translate that into ZugferdDocumentBuilder calls. This is essential if you later support XRechnung and Factur-X side by side — only the mapping layer changes.
Profile per tenant/recipient, not per app. Store the profile (EN16931 for Factur-X COMFORT, XRECHNUNG_3 for German public-sector recipients) on the customer or tenant settings row. Public-sector German recipients need XRechnung; French/B2B Factur-X is usually EN16931; MINIMUM is only useful for legacy readers.
Persist the XML, don't regenerate it. Store the generated XML (and the attached PDF) in storage/ with the invoice hash, exactly as sent. Regenerating from Eloquent months later with a different library version produces a different document — that's an audit problem. Treat the XML as an immutable artifact.
Queue it. Building a 200-line invoice and stamping a PDF takes hundreds of milliseconds to seconds. A GenerateZugferdInvoice job on the documents queue, writing to Storage::disk('invoices'), is a good default. Chain validation as a second job.
Inbound invoices: normalize with the reader. ZugferdDocumentReader::readAndGuessFromFile($path) handles both .xml and .pdf and extracts the embedded XML. Note the getters use by-reference out-parameters, so wrap a small method per field group and return an array/DTO:
$doc = ZugferdDocumentReader::readAndGuessFromFile($path);
$doc->getDocumentInformation($no, $type, $date, $currency, $lang, $notes);
$doc->getDocumentSummation($grandTotal, $duePayable, /* ... */);
Guard every out-param — they stay uninitialized/null when the field is absent.
Validation in CI and in a job. ZugferdDocumentValidator::fromFile($path)->validate() then inspect getValidationResult(). Run it as a queued job in production against every generated invoice, and as a PHPUnit test against golden fixtures in CI.
Testing. Keep a handful of real XRechnung/ZUGFeRD XML fixtures from the repo's tests/assets and assert with assertXmlFileEqualsXmlFile or a normalized snapshot. For Eloquent-backed builders, freeze invoice numbers and dates so XML is deterministic.
Laravel wiring. No service provider is required, but a small one that calls ZugferdSettings in boot() (temp folder, attachment/export folder, Java/Mustang paths) is the cleanest place for environment-specific config. Use storage_path() on the local disk, not the system /tmp, so multi-container deployments don't cross wires.
ZugferdDocumentValidator shells out to the Mustang/KoSIT validator and may download a JAR on first use. In Docker, install a JRE, pre-bake the JAR, and point ZugferdSettings at it; otherwise validation silently fails or hangs in CI. Do not let this run in a web request.setDocumentBuyerReference), BT-30 seller VAT ID or tax number, BT-34/BT-49 electronic addresses, seller contact (name/phone/email), and payment means with IBAN. EN16931 validation passing does not imply XRechnung passing — always validate against the target profile.setDocumentSummation must reconcile with line sums and per-rate tax sums; EN16931 is strict. Compute in bcmath or integer cents, round half-up to 2 decimals (amounts) / 4 decimals (quantities and unit prices), and format with number_format($v, 2, '.', '') before handing floats to the builder. One cent off = validation failure.S, Z, E, AE, K, G, O — exempt/zero-rated categories require an exemption reason; reverse charge requires the buyer's VAT ID. One addDocumentTax per category/rate pair, and matching addDocumentPositionTax on each line.addNewPosition('1') — IDs must be unique and ideally stable (SKU + index), because credit notes and corrections reference them.addDocumentNote. Ampersands and non-ASCII are handled by the serializer, but bad UTF-8 from the DB is not.getContent(). The builder is stateful; serialize once, then hand the string around. Calling getContent() repeatedly is not guaranteed to be free of side effects.ZugferdSettings is global static state. In long-running processes (Octane, queue workers) set it once during boot, not per invoice, or reset it between jobs.memory_limit on that queue or split very large invoices.ZugferdDocumentBuilder rather than patching it, and look at the companion orderx package if you need to ingest XRechnung orders. For unsupported CIUS rules, post-process the XML rather than forking.How can I help you explore Laravel packages today?