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
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
In PHP:
if (preg_match('/\s/u', $invoiceNumber) === 1) {
// broken
}
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
If the amount due is positive, there must be a due date. So:
$payable = $invoice->totals->payableAmount->toFloat();
if ($payable > 0 && $dueDate === null) {
// broken
}
Enter fullscreen mode Exit fullscreen mode
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"/>
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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 WhyHR-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.
Postscript: what these rules do not check
I found this after publishing, and it belongs here.
The first version of my document builder computed VAT with a helper that takes currency units and scales them into cents – but the taxable amount was already in cents. Every VAT figure came out a hundred times too large. 204.00 became 20400.00.
The validator reported the document as valid.
HR-BR-54 and HR-BR-55 compare the national VAT total against the document VAT total.
Both were consistently wrong, so they agreed with each other. And nothing in the national overlay checks that VAT equals taxable base × rate – that is BR-CO-17, an EN 16931 rule, not a national one.
Two things follow, and they generalise to any national CIUS:
Implementing the national rules is not implementing validation. The EN 16931 layer runs first and catches a different class of error. Skip it and arithmetic nonsense passes.
A validator that accepts your document is telling you less than you think. Mine accepts 62 rules’ worth of correctness and is silent on everything else. That is worth knowing before you trust it to gate an invoice on its way out.
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: ...", ...]
Enter fullscreen mode Exit fullscreen mode
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' => []]
Enter fullscreen mode Exit fullscreen mode
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.
Since publishing, I added a builder, because constructing the document turned out to be the harder half of the job:
$eracun = EracunBuilder::invoice('RN-2026-0001')
->issuedAt($now)->dueOn($due)
->seller($seller)->buyer($buyer)
->operator('11111111119', 'Boris')
->line('Consulting', quantity: '10', unitPrice: '80.00', kpd: '62.10.11')
->build();
Enter fullscreen mode Exit fullscreen mode
It derives the VAT breakdown, the monetary totals and the national extension from the lines, so the six reconciliation rules hold by construction rather than by luck. Four official reference invoices ship with the package as well, so there is something to try it against without hunting them down.
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.
답글 남기기