DEV Community

Cover image for You retyped the Schematron into PHP. How do you know you got it right?
Boris Stiner
Boris Stiner

Posted on

You retyped the Schematron into PHP. How do you know you got it right?

Last time I wrote about a let variable thirty lines above an assertion that quietly inverted a rule, and made my validator report a bug on every credit note. A few people replied with versions of the same question, and it is the right one:

if you cannot run the official file, and you retyped it into PHP by hand, what tells you the two still agree?

Nothing did. That is what this post is about. The answer turned out to be more useful than the Croatian rules it was built for, so the tool is written to point at any country's ruleset, and you can run it against yours.

The setup, briefly

European e-invoicing rules ship as ISO Schematron. Each country publishes a .sch file layered on top of EN 16931: Croatia's HR CIUS, Germany's XRechnung, Italy, Poland, France. When your invoice is rejected, the rejection quotes an id from that file.

Croatia's declares queryBinding="xslt2". PHP's XSLTProcessor is libxslt, which is XSLT 1.0 only. There is no flag for this. So you have three options: ship a PECL extension nobody can install, make a network call in the middle of issuing an invoice, or reimplement the rules in PHP.

I reimplemented them - 62 rules, one small class each, named after the official id.

That last choice is the one that needs defending, because reimplementation means transcription, and transcription drifts. A regex that is subtly wrong. A rule you read as applying to invoices when it applies to both invoices and credit notes. A let binding you did not notice.

You will not find these by rereading your own code. You wrote the bug by reading the file and you will reread it the same way.

The idea

You cannot run Schematron in PHP. But nothing says the comparison has to run where the validator runs.

So: run the official Schematron somewhere that does have XSLT 2.0, capture what it says about a document, run your PHP over the same document, and diff the two lists of rule ids. Any difference is a bug in the PHP until proven otherwise.

Saxon needs a JVM, which is exactly the dependency I refused to put in the package. But in CI it is free - a container that exists for ninety seconds on a GitHub runner and never touches a user's machine.

# compile the Schematron to an XSLT that emits SVRL
java -cp saxon.jar net.sf.saxon.Transform -s:rules.sch -xsl:schxslt/pipeline-for-svrl.xsl -o:compiled.xsl

# run it over a document
java -cp saxon.jar net.sf.saxon.Transform -s:invoice.xml -xsl:compiled.xsl > report.svrl
Enter fullscreen mode Exit fullscreen mode

SchXslt does the compiling. The output is SVRL, a small XML report where every failure is a <svrl:failed-assert> carrying the rule id:

<svrl:failed-assert id="HR-BR-40" location="/*:Invoice">
  <svrl:text>[HR-BR-40] - Datum izdavanja računa mora biti veći od 01.01.2026</svrl:text>
</svrl:failed-assert>
Enter fullscreen mode Exit fullscreen mode

Pull the ids out, sort them, compare to what your PHP said. That is the whole idea.

The first time I ran it, it found the credit note bug I wrote about last time. That is not a coincidence - it is the category of bug this catches, and the reason I stopped trusting my own reading of the file.

Making it not about Croatia

My first version hardcoded the Croatian .sch in two places. That made "does this PHP agree with the artifact it was transcribed from" a question you could only ask about Croatia, which is silly, because nothing about the problem is Croatian. It is a property of retyping a formal document into a programming language.

So the ruleset became a parameter: an id, a path to a .sch, a corpus, and the implementation under test behind an interface rather than my own validator class.

tools/schematron-verify.sh --list
# hr-cius-ext-2025      HR CIUS/EXT 2025
# en16931-ubl-1.3.16    EN 16931 UBL 1.3.16

tools/schematron-verify.sh hr-cius-ext-2025
php tools/compare-with-schematron.php hr-cius-ext-2025
# 20 agreed, 0 disagreed
Enter fullscreen mode Exit fullscreen mode

Point it at your country's file and your own validator, and you get the same answer about your own code.

Three things had to be right before the output was worth reading, and none of them were obvious to me in advance.

1. Not every assertion is your business

The EN 16931 UBL Schematron has 979 assertions. Only 223 of them are business rules.
The other 756 are UBL-CR-*, UBL-SR-* and UBL-DT-* - syntax restrictions saying which UBL elements a conformant document may not use.

If you diff everything against everything, every document reports several hundred "missed" rules and the real result is invisible. So a ruleset declares which id prefixes the comparison is entitled to an opinion about, and everything else is filtered from both sides before comparing.

That sounds like a detail. It is the difference between a report you read and a report you close.

2. A missing rule is not a wrong rule

This is the one I got wrong first, and it matters most if you are comparing against something other than your own complete implementation.

There are two very different findings:

  • Disagreement. Both sides have an opinion about this rule id and they differ. That is a bug.
  • Gap. The Schematron flagged an id your implementation does not implement at all. That is a coverage hole, and it is not a bug.

Report them the same way and a partially complete implementation looks catastrophically broken, while the actual defects drown in the noise. So the implementation under test declares which ids it implements, and anything outside that list is counted separately and never fails the run.

4 agreed, 0 disagreed

4 rule ids the Schematron flagged that this implementation does not implement:
  BR-CO-10       on 2 documents
  BR-CO-15       on 2 documents
  ...
Enter fullscreen mode Exit fullscreen mode

Two numbers, two meanings. "Correct as far as it goes" and "goes this far" are different claims and a report should not blur them.

3. A corpus where everything passes proves nothing

I pointed the harness at the official EN 16931 example documents and got zero failures on every one. Which is correct - they are the reference examples, they are supposed to pass - and completely useless. Two implementations agree trivially when there is nothing to disagree about.

You need documents that break. So generate them: take one known-good invoice and produce a variant per rule, each with exactly one thing wrong.

'br-co-17-vat-scaled-by-100' => [
    'rule' => 'BR-CO-17',
    'mutate' => function (DOMDocument $d) {
        foreach (xpath($d)->query('//cac:TaxSubtotal/cbc:TaxAmount') as $node) {
            $node->textContent = number_format(((float) $node->textContent) * 100, 2, '.', '');
        }
    },
],
Enter fullscreen mode Exit fullscreen mode

Ten fixtures, ten rules, each one reproducing a single failure. Checked in so they can be reviewed, generated by a script so they can be regenerated when the ruleset moves.

This is also the honest way to handle the reference documents your tax authority publishes. Croatia's twenty examples were published in December 2025; the rules were revised in March 2026 and nobody refreshed the examples. None of the twenty passes the current rules. So I never assert that they validate - I assert the measured outcome per file per rule id, and treat examples and rules as what they are: separate artifacts on separate release cycles.

What agreement does not mean

Here is the part I would want to read before trusting any of this.

Agreeing with the Schematron is not the same as being correct against the standard.

BR-CO-25 and BR-CO-27 are defined in the text of EN 16931-1:2017+A1:2019. They are absent from the published Schematron 1.3.16 - the BR-CO- range runs 03 to 26 with 25 missing - and absent from the concrete syntax bindings, while other implementations do carry them.

A rule missing from both the Schematron and your PHP cannot appear as a disagreement.
The harness will report perfect agreement and both of you will be incomplete in exactly the same way.

So the claim this tool supports is narrow and worth stating precisely: my transcription matches the artifact I transcribed it from. That is a real claim and nobody had checked mine before. It is not my validator makes your invoice valid, and no amount of green CI turns one into the other.

I now report coverage per layer rather than as one number, for the same reason. "62 of 62" was true of the Croatian overlay and read as "fully validated", when three layers sat underneath it that I do not implement at all.

Run it on yours

The harness is in the repository, MIT, and the ruleset is a parameter:

Add your country's .sch to tools/harness/rulesets.json, write a thin adapter around your validator, and you will find out in an afternoon whether your rules say what you think they say. If you do it for Germany, Poland, Italy or France, I would genuinely like to hear what you find - especially if it is a category of drift I have not hit yet.

And if you are about to reimplement a Schematron by hand: build this first. I built it second, and it immediately told me about a bug that had been shipping.

Top comments (0)