Validate EN 16931 e-invoices in CI: KoSIT + OpenPEPPOL Schematron

A blocking CI pipeline for e-invoice conformance: the official KoSIT validator for Factur-X/CII, the OpenPEPPOL Schematron compiled with SchXslt and run with Saxon for UBL, and a tiny SVRL gate — with the five real bugs it caught before any customer did.

Generating a Factur-X invoice in PHP is a solved problem. Knowing whether the document you generated will survive contact with a tax platform is not — and the usual answer, "paste it into a validation portal and eyeball the report", does not scale past the second deploy.

We wanted a different property for our invoicing engine: CI goes red if the code emits a non-conformant document. Not our interpretation of conformant — the official artefacts' interpretation, the same rule sets the receiving platforms run. That pipeline exists, it's three jobs, and it caught five real conformance bugs in our engine before any customer or tax authority saw a single document. Here is the whole setup, pinned versions included.

What "valid" means — three layers deep

An e-invoice can be broken at three distinct layers, and each needs a different tool:

  • Structure (XSD). The XML has the right elements in the right order. Necessary, nowhere near sufficient — a structurally perfect invoice can still be commercial nonsense.
  • EN 16931 business rules (Schematron). The European standard's BR-* rules: totals must reconcile, VAT categories must carry their required companions, dates and references must be present when conditions trigger. This is where real-world rejections live.
  • Profile rules (more Schematron). Whatever rides on top: Germany's XRechnung CIUS, or PEPPOL BIS Billing 3.0 if your documents travel the PEPPOL network. Same mechanism, different rulebook, different maintainer.

"It passed the XSD" therefore means almost nothing. The business rules are distributed as Schematron — XML-encoded assertions — and the trick to validating them in CI is knowing which official artefact covers which syntax.

The pipeline's shape

Everything hangs off one principle: validate documents your real engine generated, not hand-written fixtures. A console command in the app dumps reference documents through the production code path — a domestic invoice, a credit note, an intra-EU reverse-charge case, a foreign-currency invoice:

php bin/console app:einvoice:check \
  --dump-cii=build/sample-cii.xml \
  --dump-ubl=build/sample-ubl-invoice.xml \
  --dump-ubl-credit-note=build/sample-ubl-creditnote.xml

The same command doubles as a fast, dependency-free PHP gate: it validates the documents against our in-code implementation of the EN 16931 rules before any Java process enters the picture. Then two more CI jobs bring in the referees.

Job 1: the KoSIT validator (CII / Factur-X)

For the CII syntax — what Factur-X and ZUGFeRD embed — the reference implementation is the KoSIT validator, a standalone Java tool from the German coordination office for IT standards. It takes a validator configuration that maps document types to scenarios; the XRechnung configuration ships an "EN16931 (CII)" scenario that matches the EN 16931 profile Factur-X uses.

The job, condensed from our production CI (versions pinned on purpose — checked 6 July 2026):

- name: Download the pinned KoSIT validator and configuration
  run: |
    curl -sSL -o validator.zip \
      https://github.com/itplr-kosit/validator/releases/download/v1.5.0/validator-1.5.0-distribution.zip
    curl -sSL -o config.zip \
      https://github.com/itplr-kosit/validator-configuration-xrechnung/releases/download/v2026-01-31/xrechnung-3.0.2-validator-configuration-2026-01-31.zip
    unzip -q validator.zip -d validator && unzip -q config.zip -d config

- name: Run the Schematron validation
  run: |
    java -jar validator/validationtool-1.5.0-standalone.jar \
      -s config/scenarios.xml -r "$(pwd)/config" \
      build/sample-cii.xml build/sample-cii-foreign.xml

The tool exits non-zero on rejection, so the job is blocking with no glue code. One scoping gotcha: the XRechnung configuration has no scenario for PEPPOL BIS UBL — don't try to shove UBL documents through it. That's the next job's business.

Job 2: OpenPEPPOL for UBL — compile the Schematron yourself

For UBL conforming to PEPPOL BIS Billing 3.0 there is no turnkey validator jar. The peppol-bis-invoice-3 repository ships the rule sources: two Schematron files — CEN-EN16931-UBL.sch (the European standard's rules for UBL) and PEPPOL-EN16931-UBL.sch (the PEPPOL profile rules on top). You compile them to XSLT with SchXslt — the same tool OpenPEPPOL's own build uses — and apply them with Saxon-HE.

Before any Schematron: validate against the OASIS UBL 2.1 XSD. Element order in UBL is strict, and if your serializer hand-rolls the tree (ours does), the XSD is what catches an element emitted one position too early. Three lines of PHP, no extra tooling:

php -r '$d = new DOMDocument(); $d->load($argv[1]);
        exit($d->schemaValidate($argv[2]) ? 0 : 1);' \
  build/sample-ubl-invoice.xml ubl/xsd/maindoc/UBL-Invoice-2.1.xsd

Then the Schematron pass — compile once, apply to every document:

- name: Compile and run the Schematron validations
  run: |
    SCH=peppol-bis-invoice-3-3.0.20/rules/sch
    PIPELINE=schxslt/xslt/2.0/pipeline-for-svrl.xsl
    java -jar saxon-he.jar -xsl:$PIPELINE -s:$SCH/CEN-EN16931-UBL.sch  -o:cen-ubl.xslt
    java -jar saxon-he.jar -xsl:$PIPELINE -s:$SCH/PEPPOL-EN16931-UBL.sch -o:peppol-ubl.xslt
    for doc in invoice creditnote foreign; do
      for rules in cen peppol; do
        java -jar saxon-he.jar -xsl:$rules-ubl.xslt \
          -s:build/sample-ubl-$doc.xml -o:$doc-$rules.svrl
      done
    done
    bin/svrl-check *.svrl

Our pins, all current as of 6 July 2026: rule sources v3.0.20, SchXslt 1.10.1, Saxon-HE 10.9, UBL 2.1 XSD from OASIS.

Reading SVRL: the 40-line gate

Saxon doesn't fail the build — it just writes SVRL, Schematron's XML report format. The verdict is a small PHP script: collect every svrl:failed-assert, print it, and fail the run only when the assertion's flag is fatal or error (warnings are reported but don't block — PEPPOL flags genuinely advisory things, and treating them as fatal makes the gate cry wolf):

$failed = $dom->getElementsByTagNameNS('http://purl.oclc.org/dsdl/svrl', 'failed-assert');

foreach ($failed as $assert) {
    $flag = $assert->getAttribute('flag');
    in_array($flag, ['fatal', 'error'], true) ? $fatal++ : $warnings++;

    printf("  [%s] %s — %s\n", $flag, $assert->getAttribute('id'),
        trim((string) preg_replace('/\s+/', ' ', $assert->textContent)));
}

exit($fatal > 0 ? 1 : 0);

Every failed assertion prints its rule id (BR-CO-25, PEPPOL-EN16931-R010…) and message straight in the CI log, which turns a red build into a fix in minutes.

What the referee caught

The pipeline isn't theoretical hygiene. On documents our own PHP validator called clean, the official artefacts found five real bugs:

  • BR-CO-25 (KoSIT): an invoice with a positive amount due must carry a payment due date or payment terms. Ours carried neither when a merchant left the terms field empty.
  • BR-IC-11 / BR-IC-12 (KoSIT): intra-community supplies must state the actual delivery date and the deliver-to country. Nothing in the syntax forces you to think about delivery on a services invoice — the rules do.
  • PEPPOL-EN16931-R003 (OpenPEPPOL): a buyer reference or purchase-order reference is mandatory in the PEPPOL profile. Plain EN 16931 is fine without it; the profile is not.
  • PEPPOL-EN16931-R010 / R020 (OpenPEPPOL): both parties need an electronic address (the PEPPOL endpoint). We now derive them from EU VAT numbers via the EAS scheme codes (9930 for DE…, 9957 for FR…) — and learned that some countries' VAT numbers have no EAS entry at all, so those parties need a genuine PEPPOL participant identifier instead.

Each finding got mirrored back into the fast PHP gate, so regressions fail unit tests in seconds instead of a Java job minutes later. One subtlety worth copying: keep the EN 16931 checks and the PEPPOL profile checks separate. A B2C invoice with no buyer electronic address is perfectly valid Factur-X — it's only invalid as a PEPPOL document. One merged validator would force PEPPOL's stricter rules onto documents that never travel that network.

Practices that keep it green

  • Pin every artefact — validator, configuration, rule tag, SchXslt, Saxon. The configurations evolve (scenario matching included); "latest" is not a build input, it's a time bomb that detonates in an unrelated PR.
  • Validate the credit note too. It exercises different rule paths (type codes, preceding-invoice references) and it will break independently of the invoice.
  • Keep a foreign-currency document in the set if you invoice in more than one currency — the VAT-in-accounting-currency rules (BT-6/BT-111) only trigger there, and no domestic sample will ever cover them.
  • Make the jobs blocking. A nightly advisory run is a report nobody reads. A red PR is a fix.

The payoff

Sixty-odd lines of YAML, one 40-line SVRL parser, zero recurring cost — and a class of bug that used to surface as "the platform rejected my invoice" support tickets now surfaces as a failed check before merge. E-invoicing mandates are rolling across Europe on fixed dates; the official rule sets are public and versioned. Wiring them into CI is the cheapest compliance insurance you'll ever buy.

The engine this pipeline guards runs behind a production SaaS. If you're building a Symfony SaaS and want the rest of the plumbing already tested — auth with 2FA, teams, billing on two providers, an admin — that's what ShipAnvil ships, CI included. And if you missed it, the generation half of this story is in Generate Factur-X and ZUGFeRD invoices in PHP.