Hi, I'm Boris — a PHP developer whose deepest experience is TYPO3, with Laravel and
Filament a newer and growing focus. A lot of that work has been integrations that
quietly need to just work: payments, webhooks, notifications.
This one is about e-invoicing, which is now mandatory in a growing list of EU countries
and is coming for the rest. If you build anything that issues invoices B2B in Europe,
this will land on your desk eventually.
The specifics below are Croatian, because that is the one I built. But the shape is
identical in Germany (XRechnung), Poland (KSeF), Italy, France and everywhere else:
EN 16931 defines the semantic model, and each country layers its own rules on top as a
Schematron file. The trap I hit is a property of Schematron, not of Croatia.
The setup
You send an invoice. It comes back rejected:
[HR-BR-9] - Račun mora sadržavati ispravan OIB operatera
Somewhere there is a document that defines what HR-BR-9 means. In every EU
implementation I have looked at, that document is a .sch file — ISO Schematron — and
the tax authority publishes it.
Croatia's has 73 assertions under 62 distinct rule ids, every one of them
flag="fatal". No warnings. Each broken rule is a rejected invoice.
A typical one is unremarkable:
<assert test="not(matches(/*/cbc:ID, '\s'))" flag="fatal" id="HR-BR-1">
[HR-BR-1] - The invoice number must not contain whitespace
</assert>
In PHP:
if (preg_match('/\s/u', $invoiceNumber) === 1) {
// broken
}
Of the 62, about 29 are that easy — a regex, a string length, a date range, a presence
check. Sixteen more are four variants of one shape across four VAT categories. Only
seven are genuinely hard, all of them arithmetic reconciliation.
So: reimplement them in PHP and move on. Which is where it gets interesting.
PHP cannot run the file
Schematron compiles to XSLT and runs against your document. Croatia's declares
queryBinding="xslt2", so it needs XSLT 2.0.
PHP's XSLTProcessor uses libxslt, which is XSLT 1.0 only. There is no flag for this.
Your options:
- SaxonC as a PECL extension. Works, but now every server you deploy to needs a compiled extension. For a library people install with Composer, that is a non-starter.
- A remote validation service. A network dependency in the middle of issuing an invoice.
- Reimplement the rules in PHP. They are, once you take them apart, arithmetic and set membership.
Option 3 is the only sane one for production. It also has an obvious hole: how do you
know you read them correctly?
The trap
Here is a rule that looks completely unambiguous:
<assert test="($payableAmount > 0)
and (exists(cbc:DueDate) or exists(cac:PaymentMeans/cbc:PaymentDueDate))
or (($payableAmount <= 0))"
flag="fatal" id="HR-BR-4">
[HR-BR-4] - Where the payable amount (BT-115) is positive,
the payment due date (BT-9) must be given
</assert>
If the amount due is positive, there must be a due date. So:
$payable = $invoice->totals->payableAmount->toFloat();
if ($payable > 0 && $dueDate === null) {
// broken
}
That is wrong, and it took an independent check to find out.
Thirty lines above the assertion, in a place you will not look if you are scanning for
<assert> elements, sits this:
<let name="payableAmount" value="
if (/ubl-invoice:Invoice) then
cac:LegalMonetaryTotal/cbc:PayableAmount
else
cac:LegalMonetaryTotal/cbc:PayableAmount * -1"/>
For a credit note, the amount is multiplied by -1.
The reasoning is sound once you see it. A credit note carries its payable amount as a
positive number, but the money moves the other way. After the sign flip the amount is
negative, > 0 is false, and the rule never applies to credit notes at all.
Miss that, and your validator reports an error on every credit note without a due date.
Which is nearly all of them — UBL's CreditNoteType has no cbc:DueDate element in the
first place, so a credit note that needs one has to carry it in cac:PaymentMeans.
A false positive, on one of the most common documents in the system.
The lesson generalises to any Schematron you implement by hand: the assertion is the
tip. Read the let variables. In this file they sit above the rule that uses them, in
a different block, and they change the meaning of the test entirely.
How to know you got it right
This is the part I nearly skipped, and it is the part that mattered.
You do not need Saxon in production to use it in a test. Run it once, in Docker, and
diff it against your own implementation:
# Compile the Schematron into an XSLT that emits SVRL, using SchXslt
docker run --rm -v "$PWD:/w" -w /w eclipse-temurin:21-jre \
java -cp saxon.jar net.sf.saxon.Transform \
-s:rules.sch \
-xsl:schxslt/xslt/2.0/pipeline-for-svrl.xsl \
-o:compiled.xsl
# Run it over a document
docker run --rm -v "$PWD:/w" -w /w eclipse-temurin:21-jre \
java -cp saxon.jar net.sf.saxon.Transform \
-s:invoice.xml -xsl:compiled.xsl
The output is SVRL, where each <svrl:failed-assert> carries the id of the rule that
failed. Extract those, compare with what your code reports, and any disagreement is a
bug in your code until proven otherwise.
Two things that cost me time:
-
Use Saxon-HE 10.x. Version 12 wants
xmlresolveron the classpath; 10.x is one self-contained jar. -
Parse SVRL with an XML parser. It is pretty-printed with attributes across several
lines, so
grepreturns nothing on a perfectly good report and you will spend twenty minutes convinced the pipeline is broken.
First run found HR-BR-4. After fixing it: 20 documents, 0 disagreements — and the
same result again on those documents after a round-trip through my writer.
The other thing the check found
The tax authority publishes 20 reference invoices. The natural move is to turn them into
fixtures and assert that they all validate.
Do not. None of them passes the current rules:
| Rule | Files | Why |
|---|---|---|
HR-BR-40 |
20/20 | Every example is dated 2025; the rule requires 2026 onwards |
HR-BR-9 |
20/20 | The placeholder tax ID fails its checksum |
HR-BR-53 |
19/20 | Same placeholder in another field |
HR-BR-25 |
1/20 | One example omits a classification code it is not exempt from |
The explanation is mundane. The examples were published in December 2025, the rules were
revised in March 2026, and the date floor was introduced in between. Nobody refreshed
the examples.
Worth internalising if you work with any national CIUS: the examples and the rules are
different artifacts on different release cycles. Use the examples as input to test
your reader and writer. Measure the expected validation result; do not assume it.
Things a national CIUS will add that EN 16931 does not have
Briefly, because these are the ones that surprise people:
-
A mandatory operator. Croatia requires the name and tax number of the person who
issued the invoice, in
cac:AccountingSupplierParty/cac:SellerContact. If your app has no concept of "who issued this", you now need one. - A mandatory issue time. EN 16931 has a date only.
-
No empty elements.
<cbc:Note></cbc:Note>fails the document. Most XML builders happily emit an empty element for a null property, so the fix belongs in the writer: the helper that writes a value writes nothing when there is no value. - A classification code on every line, from a list of 3,359 permitted values — which is a subset of the national statistics catalogue's 5,828. Validate against the subset in the rules file, not the catalogue, or you will accept codes the authority rejects.
The package
All of the above is in stboris/laravel-eracun
— MIT, PHP 8.3+, no framework dependency:
use Stboris\Eracun\Validation\Validator;
$result = Validator::default()->validateFile('invoice.xml');
$result->brokenCodes(); // ["HR-BR-9", "HR-BR-40"]
$result->messages(); // ["[HR-BR-9] HR-BT-5: ...", ...]
Violations carry the official rule identifiers, so a message from the package matches
the code in the rejection you got from your provider. All 62 rules are implemented, and
the comparison harness above is in the repo so the claim is checkable rather than asserted:
Validator::default()->coverage(); // ['ratio' => '62/62', 'missing' => []]
It is a business-rule validator, not a conformance validator, and it makes nobody
compliant with anything. Signing, fiscalisation and transmission are deliberately out of
scope — those need a certificate or a commercial contract with a provider.
If you are building the same thing for another country, the structure should port
straight across: typed document objects, one small class per rule, and Saxon in a
container to keep yourself honest.
Curious whether others implementing a national CIUS have hit the let-variable problem,
or found a cleaner way to stay in sync with the published rules — let me know in the
comments.
Top comments (0)