E-Invoicing in Laravel: XRechnung and ZUGFeRD with KoSIT Validation
Published · Marten Ackermann
Short answer: A robust e-invoicing integration in Laravel doesn’t build its own validation rules — it lets the official KoSIT validator decide. Incoming XRechnung and ZUGFeRD files are converted into a syntax-neutral model of your own, the original stays untouched as the legal record, and every processing step lands in a hash-chained journal. Outgoing invoices go through the same validator before they are finalised.
Since 1 January 2025, businesses in Germany must be able to receive e-invoices in B2B transactions; issuing them becomes mandatory in 2027 and 2028. For PEX I built both directions: receiving with validation, archiving and DATEV export, and sending as XRechnung 3.0 and ZUGFeRD 2.x. These six decisions carried the project.
1. The validator is the referee — never re-implement the rules
The XRechnung rules consist of XSD schemas and hundreds of Schematron rules (BR-DE-…, BR-CO-…, BR-CL-…), and they change with every release. Re-implementing them in PHP means maintaining a copy that is out of date from day one.
PEX therefore uses the official KoSIT validationtool with the official XRechnung configuration — both pinned to an exact version. My own code only interprets the report: verdict, findings, severity. The content of the rules stays the validator’s responsibility.
It runs as a long-lived background process on the server, with a CLI adapter as a fallback. Installation happens through a script with checksums and a smoke test — no Docker, no root, one immutable directory per version.
2. A canonical model of your own instead of passing XML through the app
E-invoices arrive in two syntaxes (UBL and CII), sometimes embedded in PDFs (ZUGFeRD/Factur-X). If every part of the app — UI, search, DATEV export, webhooks — read XML directly, there would be two code paths everywhere.
Instead, every document is converted into a syntax-neutral model based on EN 16931. For reading I use proven libraries (josemmo/einvoicing for UBL, horstoeko/zugferd for CII, PDF extraction and edge-case fields) — but the target model is my own. That keeps every library replaceable.
3. The original is the legal record
The canonical model is the working document. Legally, what counts is the file that was received, byte for byte. It is never regenerated from the model, never “cleaned up”, never overwritten.
Idempotency follows from that: on ingest, a SHA-256 is computed over the raw bytes, and a unique constraint in the database — not a check in application code — decides whether a document already exists. Duplicate uploads are normal retries, not errors: they get the existing document back.
4. GoBD: an archive that cannot change, and a journal that proves it
Originals, validation reports and model snapshots live in a dedicated S3 bucket with Object Lock in compliance mode — a retention period per object, ten years by default. Not even an admin account can delete them early.
Every processing step is also written to an append-only journal whose entries are hash-chained:
// hash = sha256(previous_hash ∥ canonical_json(event))
public static function hash(string $prevHash, array $fields): string
{
return hash('sha256', $prevHash.self::canonicalJson($fields));
}
The canonical JSON is recursively key-sorted so the hash doesn’t depend on array order. An Artisan command verifies the whole chain — if an entry is altered after the fact, the chain breaks at exactly that point.
5. A pipeline of small, repeatable jobs
Parse, validate, normalise, archive: four steps, four separate queue jobs on a dedicated Horizon queue. Each job is idempotent and can be re-dispatched on its own. A slow validator run blocks neither other documents nor the rest of the app.
A document’s status is a PHP enum with an explicit transition table — illegal transitions throw. Documents that can’t be processed land in a quarantine view with the exact reason instead of silently disappearing.
6. Outgoing invoices: never correct, always validate
For sending, PEX maps its own invoice onto the same canonical model and generates XRechnung (UBL) or a ZUGFeRD PDF/A-3 from it. Two rules:
- Never correct silently. A line with tax category
Sat 0% is transmitted as exactly that. Totals are recomputed from the lines in integer cents and compared with the stored values; mismatches are reported, never overwritten. - Validate before finalising. The generated file goes through the same KoSIT validator. The user sees errors before the invoice reaches the recipient — not as a rejection from their accounting department.
Architecture: a package with clear boundaries
The entire e-invoicing part is its own Composer package inside the repository, built in four layers (Domain, Application, Infrastructure, Laravel). Domain and Application are plain PHP; a Pest architecture test ensures neither imports Illuminate or Symfony. The package has around 180 tests of its own, plus contract tests against the real KoSIT validator in CI.
New XRechnung versions become a configuration question: a new validator configuration, one entry in the ruleset list — no new code. Every validation records which rule version it ran against.
Conclusion
E-invoicing looks like a file-format topic, but it is really an evidence topic: which file arrived, who validated it when, against which rules, and has anything changed since? Answer those questions in the architecture rather than in process documents, and the real work is done.
Need this in your own Laravel application? That’s exactly what my fixed-price package “E-invoicing from custom PHP/Laravel B2B software” is for. The PEX case study shows the full system.