<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Boris Stiner</title>
    <description>The latest articles on DEV Community by Boris Stiner (@boris-stiner).</description>
    <link>https://dev.to/boris-stiner</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4039473%2F506e79f5-d2e5-40a2-aa1f-1d14cf0dde25.png</url>
      <title>DEV Community: Boris Stiner</title>
      <link>https://dev.to/boris-stiner</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/boris-stiner"/>
    <language>en</language>
    <item>
      <title>You retyped the Schematron into PHP. How do you know you got it right?</title>
      <dc:creator>Boris Stiner</dc:creator>
      <pubDate>Sat, 01 Aug 2026 19:07:30 +0000</pubDate>
      <link>https://dev.to/boris-stiner/you-retyped-the-schematron-into-php-how-do-you-know-you-got-it-right-me3</link>
      <guid>https://dev.to/boris-stiner/you-retyped-the-schematron-into-php-how-do-you-know-you-got-it-right-me3</guid>
      <description>&lt;p&gt;Last time I wrote about a &lt;code&gt;let&lt;/code&gt; 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:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;if you cannot run the official file, and you retyped it into PHP by hand, what tells you the two still agree?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, briefly
&lt;/h2&gt;

&lt;p&gt;European e-invoicing rules ship as ISO Schematron. Each country publishes a &lt;code&gt;.sch&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Croatia's declares &lt;code&gt;queryBinding="xslt2"&lt;/code&gt;. PHP's &lt;code&gt;XSLTProcessor&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;I reimplemented them - 62 rules, one small class each, named after the official id.&lt;/p&gt;

&lt;p&gt;That last choice is the one that needs defending, because reimplementation means &lt;strong&gt;transcription&lt;/strong&gt;, 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 &lt;code&gt;let&lt;/code&gt; binding you did not notice.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;You cannot run Schematron in PHP. But nothing says the &lt;em&gt;comparison&lt;/em&gt; has to run where the validator runs.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# compile the Schematron to an XSLT that emits SVRL&lt;/span&gt;
java &lt;span class="nt"&gt;-cp&lt;/span&gt; saxon.jar net.sf.saxon.Transform &lt;span class="nt"&gt;-s&lt;/span&gt;:rules.sch &lt;span class="nt"&gt;-xsl&lt;/span&gt;:schxslt/pipeline-for-svrl.xsl &lt;span class="nt"&gt;-o&lt;/span&gt;:compiled.xsl

&lt;span class="c"&gt;# run it over a document&lt;/span&gt;
java &lt;span class="nt"&gt;-cp&lt;/span&gt; saxon.jar net.sf.saxon.Transform &lt;span class="nt"&gt;-s&lt;/span&gt;:invoice.xml &lt;span class="nt"&gt;-xsl&lt;/span&gt;:compiled.xsl &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; report.svrl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://codeberg.org/SchXslt/schxslt" rel="noopener noreferrer"&gt;SchXslt&lt;/a&gt; does the compiling. The output is SVRL, a small XML report where every failure is a &lt;code&gt;&amp;lt;svrl:failed-assert&amp;gt;&lt;/code&gt; carrying the rule id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;svrl:failed-assert&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"HR-BR-40"&lt;/span&gt; &lt;span class="na"&gt;location=&lt;/span&gt;&lt;span class="s"&gt;"/*:Invoice"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;svrl:text&amp;gt;&lt;/span&gt;[HR-BR-40] - Datum izdavanja računa mora biti veći od 01.01.2026&lt;span class="nt"&gt;&amp;lt;/svrl:text&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/svrl:failed-assert&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull the ids out, sort them, compare to what your PHP said. That is the whole idea.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it not about Croatia
&lt;/h2&gt;

&lt;p&gt;My first version hardcoded the Croatian &lt;code&gt;.sch&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;So the ruleset became a parameter: an id, a path to a &lt;code&gt;.sch&lt;/code&gt;, a corpus, and the implementation under test behind an interface rather than my own validator class.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;schematron-diff list
&lt;span class="c"&gt;# hr-cius-ext-2025    HR CIUS/EXT 2025&lt;/span&gt;
&lt;span class="c"&gt;# en16931-ubl         EN 16931 UBL (bundled example)&lt;/span&gt;
&lt;span class="c"&gt;#                     versions: 1.3.16 (default), 1.3.15&lt;/span&gt;

schematron-diff run hr-cius-ext-2025
&lt;span class="c"&gt;# 20 agreed, 0 disagreed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point it at your country's file and your own validator, and you get the same answer about your own code.&lt;/p&gt;

&lt;p&gt;Three things had to be right before the output was worth reading, and none of them were obvious to me in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Not every assertion is your business
&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That sounds like a detail. It is the difference between a report you read and a report you close.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A missing rule is not a wrong rule
&lt;/h2&gt;

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

&lt;p&gt;There are two very different findings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disagreement.&lt;/strong&gt; Both sides have an opinion about this rule id and they differ. That is a bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gap.&lt;/strong&gt; The Schematron flagged an id your implementation does not implement at all. That is a coverage hole, and it is not a bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;11 agreed, 0 disagreed

10 rule ids the Schematron flagged that this implementation does not implement:
  BR-CL-04       on 1 document
  BR-CO-10       on 2 documents
  BR-CO-13       on 1 document
  BR-CO-14       on 1 document
  BR-CO-15       on 2 documents
  BR-CO-16       on 1 document
  BR-CO-17       on 2 documents
  BR-S-01        on 1 document
  BR-S-08        on 2 documents
  BR-S-09        on 2 documents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two numbers, two meanings. "Correct as far as it goes" and "goes this far" are different claims and a report should not blur them.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A corpus where everything passes proves nothing
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'br-co-17-vat-scaled-by-100'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'rule'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'BR-CO-17'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'mutate'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;DOMDocument&lt;/span&gt; &lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;xpath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'//cac:TaxSubtotal/cbc:TaxAmount'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;number_format&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="n"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven fixtures, each reproducing a single failure. Checked in so they can be reviewed, generated by a script so they can be regenerated when the ruleset moves.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;measured&lt;/em&gt; outcome per file per rule id, and treat examples and rules as what they are: separate artifacts on separate release cycles.&lt;/p&gt;

&lt;h2&gt;
  
  
  What agreement does not mean
&lt;/h2&gt;

&lt;p&gt;Here is the part I would want to read before trusting any of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agreeing with the Schematron is not the same as being correct against the standard.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had this backwards when I first published, and the correction is more interesting than the mistake. &lt;code&gt;BR-CO-25&lt;/code&gt; is not missing from Schematron 1.3.16 by oversight. It was &lt;strong&gt;removed&lt;/strong&gt; there, and it is present in 1.3.13, 1.3.14 and 1.3.15 - asserted twice in 1.3.15's compiled UBL XSLT, and in the CII binding too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;((.&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="n"&gt;cbc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;DueDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="n"&gt;cac&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;PaymentTerms&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;cbc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(.&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a positive amount due for payment with no due date and no payment terms was fatal under 1.3.15 and is valid under 1.3.16. Anyone who implemented that rule from the 2017 standard text is now rejecting compliant invoices.&lt;/p&gt;

&lt;p&gt;Which flips the conclusion. Diffing against a pinned artifact is not a weaker substitute for checking against the standard's text - it is the thing you actually want, because the artifact moves and the text does not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BR-CO-27&lt;/code&gt; turned out to be a variation on the same theme rather than a counter-example, and I got this wrong before checking properly. It is absent from every UBL binding from 1.3.13 to 1.3.16, so a UBL-only implementation never had it to lose. But it is present in the CII binding for 1.3.13 and 1.3.14 and gone from 1.3.15 onward. Same story as &lt;code&gt;BR-CO-25&lt;/code&gt;, one binding over and one release earlier.&lt;/p&gt;

&lt;p&gt;Which is worth saying plainly: "missing from the Schematron" is not one fact. It depends on which binding and which release you mean, and a rule can be live in CII while it has never existed in UBL. If your implementation is bound to one syntax, that is the only history that applies to you.&lt;/p&gt;

&lt;p&gt;The pin needs a second axis, then. A checksum tells you the ruleset file changed; it does not tell you whether anything you care about changed with it. So a ruleset can now pin several versions, and the corpus records which documents get a different verdict from each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;schematron-diff flips en16931-ubl 1.3.15 1.3.16
&lt;span class="c"&gt;# 1 document(s) change verdict between 1.3.15 and 1.3.16:&lt;/span&gt;
&lt;span class="c"&gt;#   br-co-25-payable-without-due-date.xml&lt;/span&gt;
&lt;span class="c"&gt;#     1.3.15     BR-CO-25&lt;/span&gt;
&lt;span class="c"&gt;#     1.3.16     (clean)&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;#   stopped firing in 1.3.16: BR-CO-25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A version bump becomes a decision you make rather than a behaviour change you discover. Thanks to &lt;a href="https://dev.to/to21as"&gt;Tobias&lt;/a&gt; for both the correction and the fixture idea.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it on yours
&lt;/h2&gt;

&lt;p&gt;I pulled the harness out into its own package, because the one part of this that is not Croatian was buried inside a Croatian package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require &lt;span class="nt"&gt;--dev&lt;/span&gt; stboris/schematron-diff
vendor/bin/schematron-diff run en16931-ubl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It ships with the EN 16931 UBL Schematron and a corpus of deliberately broken documents, so it does something the moment you install it. Point it at your own ruleset by declaring one in &lt;code&gt;schematron-diff.json&lt;/code&gt; and writing a thin adapter around your validator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rulesets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"my-cius"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"schematron"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"resources/rules.sch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"corpus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tests/corpus"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"implementation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"App&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;Testing&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;MyValidatorAdapter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"XX-BR-"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"sha256"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The corpus is usable without the tool and without PHP: each one ships an &lt;code&gt;expected.json&lt;/code&gt; listing the rule ids every document should produce, measured by running the Schematron rather than transcribed from the spec. Take the XML and that manifest into a .NET, Java or Python suite and run them with whatever you already have.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/stboris/schematron-diff" rel="noopener noreferrer"&gt;github.com/stboris/schematron-diff&lt;/a&gt; - MIT. A local JVM is used if you have one, otherwise a throwaway container; neither becomes a dependency of the code being tested.&lt;/p&gt;

&lt;p&gt;If you run 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>php</category>
      <category>testing</category>
      <category>xml</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Kako provjeriti eRačun prije slanja: 62 pravila i jedna zamka</title>
      <dc:creator>Boris Stiner</dc:creator>
      <pubDate>Thu, 30 Jul 2026 08:29:59 +0000</pubDate>
      <link>https://dev.to/boris-stiner/kako-provjeriti-eracun-prije-slanja-62-pravila-i-jedna-zamka-42d2</link>
      <guid>https://dev.to/boris-stiner/kako-provjeriti-eracun-prije-slanja-62-pravila-i-jedna-zamka-42d2</guid>
      <description>&lt;p&gt;Od 1. siječnja 2026. razmjena eRačuna u B2B segmentu je obavezna. Ako gradite&lt;br&gt;
integraciju, prije ili kasnije dobit ćete od posrednika odbijenicu s porukom tipa:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[HR-BR-9] - Račun mora sadržavati ispravan OIB operatera
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ovaj tekst objašnjava odakle taj kod dolazi, gdje su sva pravila zapisana, zašto ih&lt;br&gt;
ne možete jednostavno pokrenuti iz PHP-a, i koja jedna zamka u njima gotovo sigurno&lt;br&gt;
čeka svakoga tko ih implementira čitajući ih.&lt;/p&gt;

&lt;p&gt;Primjeri su u PHP-u, ali sve osim zadnjeg poglavlja vrijedi bez obzira na jezik.&lt;/p&gt;


&lt;h2&gt;
  
  
  Gdje su pravila zapisana
&lt;/h2&gt;

&lt;p&gt;Porezna uprava objavljuje &lt;strong&gt;Schematron&lt;/strong&gt; — datoteku s pravilima poslovne logike koja&lt;br&gt;
se primjenjuje nakon provjere prema EN 16931. Nalazi se u sekciji&lt;br&gt;
&lt;a href="https://porezna.gov.hr/fiskalizacija/bezgotovinski-racuni/eracun" rel="noopener noreferrer"&gt;eRačun / dokumentacija&lt;/a&gt;,&lt;br&gt;
pod nazivom &lt;em&gt;Validator&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Unutra su dvije datoteke:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HRUBLSchematron/
  HR-CIUS-EXT-EN16931-UBL.sch
  codelist/HR-CIUS-EXT-EN16931-UBL-codes.sch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verzija koja je u primjeni objavljena je &lt;strong&gt;13. ožujka 2026.&lt;/strong&gt;, a primjenjuje se od&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;ožujka 2026. Vrijedi to zapamtiti: pravila su se mijenjala &lt;em&gt;nakon&lt;/em&gt; što je obveza
već stupila na snagu, i mijenjat će se opet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ako izbrojite tvrdnje (&lt;code&gt;assert&lt;/code&gt;) unutra, dobit ćete &lt;strong&gt;73 tvrdnje pod 62 različite&lt;br&gt;
oznake&lt;/strong&gt; — neka se pravila provjeravaju na više mjesta u dokumentu. Sve su označene&lt;br&gt;
&lt;code&gt;flag="fatal"&lt;/code&gt;. Nema upozorenja; svako prekršeno pravilo znači odbijen račun.&lt;/p&gt;

&lt;p&gt;Numeracija nije neprekinuta. Nedostaju 3, 8, 12, 14, 15, 22, 23, 24, 31, 35, 38 i 39&lt;br&gt;
— povučena su ili rezervirana. Nemojte pisati petlju od 1 do 56.&lt;/p&gt;
&lt;h2&gt;
  
  
  Zašto ih ne možete pokrenuti iz PHP-a
&lt;/h2&gt;

&lt;p&gt;Schematron se prevodi u XSLT i pokreće nad dokumentom. Hrvatski Schematron ima&lt;br&gt;
&lt;code&gt;queryBinding="xslt2"&lt;/code&gt;, dakle traži &lt;strong&gt;XSLT 2.0&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;PHP-ov &lt;code&gt;XSLTProcessor&lt;/code&gt; koristi libxslt, koji podržava samo XSLT 1.0. Ne postoji&lt;br&gt;
prekidač koji to mijenja.&lt;/p&gt;

&lt;p&gt;Ostaju vam tri opcije:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SaxonC kao PECL ekstenzija.&lt;/strong&gt; Radi, ali znači da svaki server na koji deployate
mora imati prevedenu ekstenziju. Za biblioteku koju netko instalira Composerom to
je neprihvatljivo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vanjski servis.&lt;/strong&gt; Ovisnost o mreži usred izdavanja računa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reimplementirati pravila u PHP-u.&lt;/strong&gt; Pravila su, kad ih rastavite, obična
aritmetika i provjera pripadnosti skupu.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Treća opcija je jedina razumna za produkciju — ali ima očitu zamku: kako znate da ste&lt;br&gt;
ih dobro pročitali?&lt;/p&gt;
&lt;h2&gt;
  
  
  Kako pravila izgledaju
&lt;/h2&gt;

&lt;p&gt;Većina ih je bezazlena. &lt;code&gt;HR-BR-1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;assert&lt;/span&gt; &lt;span class="na"&gt;test=&lt;/span&gt;&lt;span class="s"&gt;"not(matches(/*/cbc:ID, '\s'))"&lt;/span&gt; &lt;span class="na"&gt;flag=&lt;/span&gt;&lt;span class="s"&gt;"fatal"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"HR-BR-1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  [HR-BR-1] - Broj računa ne smije sadržavati bjeline
&lt;span class="nt"&gt;&amp;lt;/assert&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;U PHP-u:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/\s/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$brojRacuna&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// prekršeno&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Od 62 pravila, njih 29 je otprilike ove težine: regularni izraz, duljina niza, raspon&lt;br&gt;
datuma, provjera postojanja. Još 16 su četiri varijante istog oblika za četiri&lt;br&gt;
kategorije PDV-a. Ozbiljnih ih je sedam — usklađivanje iznosa unutar hrvatskog&lt;br&gt;
proširenja.&lt;/p&gt;
&lt;h2&gt;
  
  
  Zamka: HR-BR-4
&lt;/h2&gt;

&lt;p&gt;A onda dođete do ovoga:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;assert&lt;/span&gt; &lt;span class="na"&gt;test=&lt;/span&gt;&lt;span class="s"&gt;"($payableAmount &amp;gt; 0)
              and (exists(cbc:DueDate) or exists(cac:PaymentMeans/cbc:PaymentDueDate))
              or (($payableAmount &amp;amp;lt;= 0))"&lt;/span&gt;
        &lt;span class="na"&gt;flag=&lt;/span&gt;&lt;span class="s"&gt;"fatal"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"HR-BR-4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  [HR-BR-4] - U slučaju pozitivnog iznosa koji dospijeva na plaćanje (BT-115),
              datum dospijeća plaćanja (BT-9) mora biti naveden
&lt;span class="nt"&gt;&amp;lt;/assert&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Čita se sasvim jasno: ako je iznos za plaćanje veći od nule, mora postojati datum&lt;br&gt;
dospijeća. Napišete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$payable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$racun&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;payableAmount&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toFloat&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payable&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$datumDospijeca&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// prekršeno&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I to je &lt;strong&gt;netočno.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Trideset redaka iznad te tvrdnje stoji definicija varijable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;let&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"payableAmount"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"
    if (/ubl-invoice:Invoice) then
        cac:LegalMonetaryTotal/cbc:PayableAmount
    else
        cac:LegalMonetaryTotal/cbc:PayableAmount * -1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Za &lt;strong&gt;odobrenje&lt;/strong&gt; (&lt;code&gt;CreditNote&lt;/code&gt;) iznos se množi s &lt;code&gt;-1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Razlog je smislen: odobrenje u XML-u nosi pozitivan iznos, ali novac ide u suprotnom&lt;br&gt;
smjeru. Nakon promjene predznaka iznos je negativan, uvjet &lt;code&gt;&amp;gt; 0&lt;/code&gt; nije zadovoljen, i&lt;br&gt;
pravilo se na odobrenja &lt;strong&gt;nikad ne primjenjuje&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Ako to promašite, vaš validator prijavljuje grešku na &lt;strong&gt;svakom odobrenju bez datuma&lt;br&gt;
dospijeća&lt;/strong&gt; — a odobrenja rijetko imaju datum dospijeća, jer &lt;code&gt;CreditNoteType&lt;/code&gt; u UBL-u&lt;br&gt;
uopće nema element &lt;code&gt;cbc:DueDate&lt;/code&gt;. Dobili ste lažno pozitivan rezultat na vrlo čestom&lt;br&gt;
dokumentu.&lt;/p&gt;

&lt;p&gt;Pouka je jednostavna i vrijedi za cijelu datoteku: &lt;strong&gt;čitajte &lt;code&gt;let&lt;/code&gt; varijable, ne samo&lt;br&gt;
tvrdnje.&lt;/strong&gt; Tvrdnja je često samo vrh; definicija je iznad nje.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ostale hrvatske specifičnosti
&lt;/h2&gt;

&lt;p&gt;Ove nemaju europski ekvivalent, pa ih integratori redovito propuste:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operater je obavezan.&lt;/strong&gt; &lt;code&gt;HR-BT-4&lt;/code&gt; i &lt;code&gt;HR-BT-5&lt;/code&gt;, u&lt;br&gt;
&lt;code&gt;cac:AccountingSupplierParty/cac:SellerContact&lt;/code&gt; — ime i OIB osobe koja je izdala&lt;br&gt;
račun. Pravila &lt;code&gt;HR-BR-37&lt;/code&gt; i &lt;code&gt;HR-BR-9&lt;/code&gt;. Ako vaša aplikacija nema pojam „tko je izdao&lt;br&gt;
ovaj račun", morat ćete ga uvesti.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;cac:SellerContact&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;cbc:ID&amp;gt;&lt;/span&gt;12345678901&lt;span class="nt"&gt;&amp;lt;/cbc:ID&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;cbc:Name&amp;gt;&lt;/span&gt;Operater1&lt;span class="nt"&gt;&amp;lt;/cbc:Name&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/cac:SellerContact&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Vrijeme izdavanja je obavezno&lt;/strong&gt;, u formatu &lt;code&gt;hh:mm:ss&lt;/code&gt; (&lt;code&gt;HR-BR-2&lt;/code&gt;). EN 16931 uopće&lt;br&gt;
nema vrijeme izdavanja, samo datum.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prazni XML elementi su zabranjeni&lt;/strong&gt; (&lt;code&gt;HR-BR-33&lt;/code&gt;), osim unutar bloka za potpis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- ovo ruši inače savršen račun --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:Note&amp;gt;&amp;lt;/cbc:Note&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ovo je vjerojatno najčešći uzrok odbijanja koji nema veze sa sadržajem računa. Većina&lt;br&gt;
generatora XML-a rado ispiše prazan element za &lt;code&gt;null&lt;/code&gt; vrijednost. Rješenje je da&lt;br&gt;
funkcija koja upisuje element jednostavno ne upiše ništa kad vrijednost ne postoji.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KPD oznaka na svakoj stavci&lt;/strong&gt;, najmanje šest znamenki, s atributom &lt;code&gt;listID="CG"&lt;/code&gt;&lt;br&gt;
(&lt;code&gt;HR-BR-25&lt;/code&gt;). Uz jednu iznimku koja se lako previdi: obveza otpada za vrste dokumenata&lt;br&gt;
&lt;code&gt;386 81 83 261 262 296 308 381 396 420 458 532&lt;/code&gt;. Zato službeni primjeri odobrenja i&lt;br&gt;
računa za predujam uopće nemaju klasifikaciju.&lt;/p&gt;

&lt;p&gt;Još nešto o KPD-u: klasifikacija KPD 2025. koju objavljuje DZS ima &lt;strong&gt;5.828 unosa&lt;/strong&gt; na&lt;br&gt;
tri razine, ali na stavci računa vrijedi samo &lt;strong&gt;3.359 šesteroznamenkastih oznaka&lt;/strong&gt;.&lt;br&gt;
Popis dopuštenih je ugrađen u &lt;code&gt;HR-BR-CL-2&lt;/code&gt; u datoteci s kodovima. Provjeravate li&lt;br&gt;
prema DZS katalogu, propustit ćete oznake koje će Porezna odbiti.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Datum izdavanja mora biti od 1. 1. 2026. nadalje&lt;/strong&gt; (&lt;code&gt;HR-BR-40&lt;/code&gt;). Nema&lt;br&gt;
retroaktivnog izdavanja.&lt;/p&gt;
&lt;h2&gt;
  
  
  Kako provjeriti da ste dobro implementirali
&lt;/h2&gt;

&lt;p&gt;Ovo je dio koji se preskače, a najviše vrijedi.&lt;/p&gt;

&lt;p&gt;Ne morate Saxon vući u produkciju da biste ga koristili u provjeri. Pokrenete ga&lt;br&gt;
jednom, u Dockeru, i usporedite s vlastitom implementacijom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Schematron -&amp;gt; XSLT, pomoću SchXslt&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;:/w"&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /w eclipse-temurin:21-jre &lt;span class="se"&gt;\&lt;/span&gt;
  java &lt;span class="nt"&gt;-cp&lt;/span&gt; saxon.jar net.sf.saxon.Transform &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-s&lt;/span&gt;:HR-CIUS-EXT-EN16931-UBL.sch &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-xsl&lt;/span&gt;:schxslt/xslt/2.0/pipeline-for-svrl.xsl &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-o&lt;/span&gt;:hr-cius.xsl

&lt;span class="c"&gt;# 2. pokrenite nad dokumentom -&amp;gt; SVRL izvještaj&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;:/w"&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /w eclipse-temurin:21-jre &lt;span class="se"&gt;\&lt;/span&gt;
  java &lt;span class="nt"&gt;-cp&lt;/span&gt; saxon.jar net.sf.saxon.Transform &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-s&lt;/span&gt;:racun.xml &lt;span class="nt"&gt;-xsl&lt;/span&gt;:hr-cius.xsl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rezultat je SVRL — XML u kojem svaki &lt;code&gt;&amp;lt;svrl:failed-assert&amp;gt;&lt;/code&gt; nosi &lt;code&gt;id&lt;/code&gt; prekršenog&lt;br&gt;
pravila. To izvučete i usporedite s onim što prijavljuje vaš kod.&lt;/p&gt;

&lt;p&gt;Dvije napomene iz iskustva:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Koristite &lt;strong&gt;Saxon-HE 10.x&lt;/strong&gt;. Verzija 12 traži &lt;code&gt;xmlresolver&lt;/code&gt; na classpathu, 10.x je
jedan samodostatan JAR.&lt;/li&gt;
&lt;li&gt;SVRL ispisuje atribute u više redaka, pa ga parsirajte XML parserom. &lt;code&gt;grep&lt;/code&gt; radi po
redcima i vratit će vam prazan rezultat na potpuno ispravnom izvještaju.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Kad sam ovo napravio za svoju implementaciju, prvi pokretanje je našlo upravo onaj&lt;br&gt;
&lt;code&gt;HR-BR-4&lt;/code&gt; iz gornjeg poglavlja. Nakon ispravka: &lt;strong&gt;20 dokumenata, 0 razlika.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Iznenađenje: službeni primjeri ne prolaze
&lt;/h2&gt;

&lt;p&gt;Porezna objavljuje 20 primjera eRačuna (&lt;em&gt;Primjeri eRačuna&lt;/em&gt;, 12. 12. 2025.). Prirodno&lt;br&gt;
je od njih napraviti testove i napisati „svi primjeri moraju proći".&lt;/p&gt;

&lt;p&gt;Nemojte. &lt;strong&gt;Nijedan od njih ne prolazi trenutna pravila:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pravilo&lt;/th&gt;
&lt;th&gt;Primjera&lt;/th&gt;
&lt;th&gt;Zašto&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HR-BR-40&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20/20&lt;/td&gt;
&lt;td&gt;Svi primjeri datirani su u 2025., a pravilo traži 2026. nadalje&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HR-BR-9&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20/20&lt;/td&gt;
&lt;td&gt;OIB prodavatelja &lt;code&gt;12345678901&lt;/code&gt; ne prolazi kontrolnu znamenku&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HR-BR-53&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;19/20&lt;/td&gt;
&lt;td&gt;Isti OIB kao porezni identifikator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HR-BR-25&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1/20&lt;/td&gt;
&lt;td&gt;Primjer za leasing je vrsta 394, koja nije izuzeta od KPD-a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Objašnjenje je posve prozaično: primjeri su objavljeni u prosincu 2025., Schematron je&lt;br&gt;
revidiran u ožujku 2026., i donja granica datuma je uvedena između. Primjeri nisu&lt;br&gt;
osvježeni.&lt;/p&gt;

&lt;p&gt;Praktična posljedica: primjeri su odlični kao &lt;strong&gt;ulaz&lt;/strong&gt; za testiranje čitanja i&lt;br&gt;
zapisivanja, ali očekivani rezultat validacije morate izmjeriti, ne pretpostaviti.&lt;/p&gt;
&lt;h2&gt;
  
  
  Što ova pravila NE provjeravaju
&lt;/h2&gt;

&lt;p&gt;Prva verzija mog buildera računala je PDV pomoću pomoćne funkcije koja prima iznos u eurima i pretvara ga u cente — ali osnovica je već bila u centima. Svaki iznos PDV-a izašao je sto puta veći. &lt;code&gt;204,00&lt;/code&gt; je postalo &lt;code&gt;20400,00&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Validator je dokument prijavio kao &lt;strong&gt;valjan&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;HR-BR-54&lt;/code&gt; i &lt;code&gt;HR-BR-55&lt;/code&gt; uspoređuju HR ukupni PDV s ukupnim PDV-om dokumenta. Oba su bila pogrešna na isti način, pa su se međusobno poklapala. A u hrvatskom sloju pravila &lt;strong&gt;ne postoji provjera da je PDV jednak osnovici pomnoženoj sa stopom&lt;/strong&gt; — to je &lt;code&gt;BR-CO-17&lt;/code&gt;,&lt;br&gt;
pravilo iz EN 16931, a ne hrvatsko.&lt;/p&gt;

&lt;p&gt;Slijede dva zaključka:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implementirati hrvatska pravila nije isto što i implementirati validaciju.&lt;/strong&gt; Sloj EN 16931 izvršava se prije hrvatskog i hvata drugu vrstu greške. Preskočite ga i aritmetičke besmislice prolaze.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validator koji prihvati vaš dokument govori vam manje nego što mislite.&lt;/strong&gt; Moj provjerava 62 pravila i o svemu ostalom je tih. To je dobro znati prije nego mu prepustite odluku pušta li se račun dalje.&lt;/p&gt;
&lt;h2&gt;
  
  
  Paket
&lt;/h2&gt;

&lt;p&gt;Sve gore opisano implementirano je u&lt;br&gt;
&lt;a href="https://github.com/stboris/laravel-eracun" rel="noopener noreferrer"&gt;&lt;code&gt;stboris/laravel-eracun&lt;/code&gt;&lt;/a&gt; — MIT, PHP 8.3+,&lt;br&gt;
bez ovisnosti o frameworku:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Stboris\Eracun\Validation\Validator&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$rezultat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Validator&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validateFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'racun.xml'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$rezultat&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;brokenCodes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// ["HR-BR-9", "HR-BR-40"]&lt;/span&gt;
&lt;span class="nv"&gt;$rezultat&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// ["[HR-BR-9] HR-BT-5: Račun mora sadržavati ...", ...]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Poruke nose službene oznake pravila, pa se poklapaju s onim što vam javi posrednik.&lt;/p&gt;

&lt;p&gt;Sva 62 pravila su implementirana i provjerena gore opisanim postupkom. Što paket provjerava, a što ne, možete pitati njega samog umjesto da vjerujete dokumentaciji:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Validator&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;coverage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// ['ratio' =&amp;gt; '62/62', 'missing' =&amp;gt; []]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Da bude jasno: to je &lt;strong&gt;validator poslovnih pravila, a ne validator sukladnosti&lt;/strong&gt;.&lt;br&gt;
Prolazak ne jamči da će dokument biti prihvaćen, i paket nikoga ne čini usklađenim sa Zakonom o fiskalizaciji. Fiskalizacija, slanje preko informacijskog posrednika i potpisivanje namjerno su izvan opsega — za njih treba certifikat ili ugovor.&lt;/p&gt;

&lt;p&gt;Nakon objave dodao sam i builder, jer se sastavljanje dokumenta pokazalo težim dijelom posla:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$eracun&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EracunBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'RN-2026-0001'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;issuedAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dueOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$due&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;seller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$prodavatelj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;buyer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$kupac&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'11111111119'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Boris'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Programiranje'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'10'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unitPrice&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'80.00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kpd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'62.10.11'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sam izvodi raspodjelu PDV-a, ukupne iznose i HR proširenje iz stavki, pa šest pravila koja provjeravaju usklađenost iznosa vrijede po konstrukciji, a ne slučajno. S paketom sada dolaze i četiri službena primjera eRačuna, pa ima na čemu isprobati.&lt;/p&gt;

&lt;p&gt;Za polja i oznake postoji &lt;a href="https://github.com/stboris/laravel-eracun/blob/main/docs/reference.md" rel="noopener noreferrer"&gt;referenca&lt;/a&gt; sa svim BT oznakama, kardinalnostima i pripadajućim PHP svojstvima.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ako naiđete na dokument koji paket prihvaća a posrednik odbije — to je greška u&lt;br&gt;
paketu i zanima me. Otvorite issue s priloženim XML-om.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>eračun</category>
      <category>php</category>
      <category>laravel</category>
      <category>fiskalizacija</category>
    </item>
    <item>
      <title>Implementing EN 16931 national rules in PHP - and the trap that faked a bug on every credit note</title>
      <dc:creator>Boris Stiner</dc:creator>
      <pubDate>Thu, 30 Jul 2026 07:14:49 +0000</pubDate>
      <link>https://dev.to/boris-stiner/implementing-en-16931-national-rules-in-php-and-the-trap-that-faked-a-bug-on-every-credit-note-4c76</link>
      <guid>https://dev.to/boris-stiner/implementing-en-16931-national-rules-in-php-and-the-trap-that-faked-a-bug-on-every-credit-note-4c76</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;strong&gt;EN 16931 defines the semantic model, and each country layers its own rules on top as a Schematron file.&lt;/strong&gt; The trap I hit is a property of Schematron, not of Croatia.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;You send an invoice. It comes back rejected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[HR-BR-9] - Račun mora sadržavati ispravan OIB operatera
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Somewhere there is a document that defines what &lt;code&gt;HR-BR-9&lt;/code&gt; means. In every EU implementation I have looked at, that document is a &lt;code&gt;.sch&lt;/code&gt; file - ISO Schematron - and the tax authority publishes it.&lt;/p&gt;

&lt;p&gt;Croatia's has &lt;strong&gt;73 assertions under 62 distinct rule ids&lt;/strong&gt;, every one of them &lt;code&gt;flag="fatal"&lt;/code&gt;. No warnings. Each broken rule is a rejected invoice.&lt;/p&gt;

&lt;p&gt;A typical one is unremarkable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;assert&lt;/span&gt; &lt;span class="na"&gt;test=&lt;/span&gt;&lt;span class="s"&gt;"not(matches(/*/cbc:ID, '\s'))"&lt;/span&gt; &lt;span class="na"&gt;flag=&lt;/span&gt;&lt;span class="s"&gt;"fatal"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"HR-BR-1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  [HR-BR-1] - The invoice number must not contain whitespace
&lt;span class="nt"&gt;&amp;lt;/assert&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In PHP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/\s/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$invoiceNumber&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// broken&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So: reimplement them in PHP and move on. Which is where it gets interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  PHP cannot run the file
&lt;/h2&gt;

&lt;p&gt;Schematron compiles to XSLT and runs against your document. Croatia's declares &lt;code&gt;queryBinding="xslt2"&lt;/code&gt;, so it needs &lt;strong&gt;XSLT 2.0&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;PHP's &lt;code&gt;XSLTProcessor&lt;/code&gt; uses libxslt, which is XSLT 1.0 only. There is no flag for this.&lt;br&gt;
Your options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SaxonC as a PECL extension.&lt;/strong&gt; Works, but now every server you deploy to needs a
compiled extension. For a library people install with Composer, that is a non-starter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A remote validation service.&lt;/strong&gt; A network dependency in the middle of issuing an
invoice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reimplement the rules in PHP.&lt;/strong&gt; They are, once you take them apart, arithmetic and
set membership.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Option 3 is the only sane one for production. It also has an obvious hole: how do you know you read them correctly?&lt;/p&gt;
&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;Here is a rule that looks completely unambiguous:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;assert&lt;/span&gt; &lt;span class="na"&gt;test=&lt;/span&gt;&lt;span class="s"&gt;"($payableAmount &amp;gt; 0)
              and (exists(cbc:DueDate) or exists(cac:PaymentMeans/cbc:PaymentDueDate))
              or (($payableAmount &amp;amp;lt;= 0))"&lt;/span&gt;
        &lt;span class="na"&gt;flag=&lt;/span&gt;&lt;span class="s"&gt;"fatal"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"HR-BR-4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  [HR-BR-4] - Where the payable amount (BT-115) is positive,
              the payment due date (BT-9) must be given
&lt;span class="nt"&gt;&amp;lt;/assert&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the amount due is positive, there must be a due date. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$payable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;payableAmount&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toFloat&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payable&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$dueDate&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// broken&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is wrong, and it took an independent check to find out.&lt;/p&gt;

&lt;p&gt;Thirty lines above the assertion, in a place you will not look if you are scanning for &lt;code&gt;&amp;lt;assert&amp;gt;&lt;/code&gt; elements, sits this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;let&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"payableAmount"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"
    if (/ubl-invoice:Invoice) then
        cac:LegalMonetaryTotal/cbc:PayableAmount
    else
        cac:LegalMonetaryTotal/cbc:PayableAmount * -1"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a &lt;strong&gt;credit note&lt;/strong&gt;, the amount is multiplied by &lt;code&gt;-1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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, &lt;code&gt;&amp;gt; 0&lt;/code&gt; is false, and &lt;strong&gt;the rule never applies to credit notes at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Miss that, and your validator reports an error on every credit note without a due date. &lt;br&gt;
Which is nearly all of them - UBL's &lt;code&gt;CreditNoteType&lt;/code&gt; has no &lt;code&gt;cbc:DueDate&lt;/code&gt; element in the first place, so a credit note that needs one has to carry it in &lt;code&gt;cac:PaymentMeans&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A false positive, on one of the most common documents in the system.&lt;/p&gt;

&lt;p&gt;The lesson generalises to any Schematron you implement by hand: &lt;strong&gt;the assertion is the tip. Read the &lt;code&gt;let&lt;/code&gt; variables.&lt;/strong&gt; In this file they sit above the rule that uses them, in a different block, and they change the meaning of the test entirely.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to know you got it right
&lt;/h2&gt;

&lt;p&gt;This is the part I nearly skipped, and it is the part that mattered.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Compile the Schematron into an XSLT that emits SVRL, using SchXslt&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;:/w"&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /w eclipse-temurin:21-jre &lt;span class="se"&gt;\&lt;/span&gt;
  java &lt;span class="nt"&gt;-cp&lt;/span&gt; saxon.jar net.sf.saxon.Transform &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-s&lt;/span&gt;:rules.sch &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-xsl&lt;/span&gt;:schxslt/xslt/2.0/pipeline-for-svrl.xsl &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-o&lt;/span&gt;:compiled.xsl

&lt;span class="c"&gt;# Run it over a document&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;:/w"&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /w eclipse-temurin:21-jre &lt;span class="se"&gt;\&lt;/span&gt;
  java &lt;span class="nt"&gt;-cp&lt;/span&gt; saxon.jar net.sf.saxon.Transform &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-s&lt;/span&gt;:invoice.xml &lt;span class="nt"&gt;-xsl&lt;/span&gt;:compiled.xsl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is SVRL, where each &lt;code&gt;&amp;lt;svrl:failed-assert&amp;gt;&lt;/code&gt; carries the &lt;code&gt;id&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Two things that cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use Saxon-HE 10.x.&lt;/strong&gt; Version 12 wants &lt;code&gt;xmlresolver&lt;/code&gt; on the classpath; 10.x is one
self-contained jar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parse SVRL with an XML parser.&lt;/strong&gt; It is pretty-printed with attributes across several
lines, so &lt;code&gt;grep&lt;/code&gt; returns nothing on a perfectly good report and you will spend twenty
minutes convinced the pipeline is broken.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; a reader pointed out that this comparison had no business being a&lt;br&gt;
command I remember to run, and he was right. The objection that kept it out of&lt;br&gt;
CI was that the package must not need a JVM - which confused two machines. The&lt;br&gt;
validator runs in the user's app and still touches no Java. The &lt;em&gt;diff&lt;/em&gt; runs on&lt;br&gt;
a CI runner, where Saxon costs nothing. It is now a workflow that fires on every&lt;br&gt;
push and every tag, caching the compiled XSLT against the hash of the &lt;code&gt;.sch&lt;/code&gt;&lt;br&gt;
files, so the expensive step only repeats when the rules actually change. A cold&lt;br&gt;
run is under a minute. If you take one thing from this section, take that one:&lt;br&gt;
the JVM only has to exist where the comparison runs.&lt;/p&gt;

&lt;p&gt;First run found &lt;code&gt;HR-BR-4&lt;/code&gt;. After fixing it: &lt;strong&gt;20 documents, 0 disagreements&lt;/strong&gt; - and the same result again on those documents after a round-trip through my writer.&lt;/p&gt;
&lt;h2&gt;
  
  
  The other thing the check found
&lt;/h2&gt;

&lt;p&gt;The tax authority publishes 20 reference invoices. The natural move is to turn them into fixtures and assert that they all validate.&lt;/p&gt;

&lt;p&gt;Do not. &lt;strong&gt;None of them passes the current rules:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HR-BR-40&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20/20&lt;/td&gt;
&lt;td&gt;Every example is dated 2025; the rule requires 2026 onwards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HR-BR-9&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20/20&lt;/td&gt;
&lt;td&gt;The placeholder tax ID fails its checksum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HR-BR-53&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;19/20&lt;/td&gt;
&lt;td&gt;Same placeholder in another field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HR-BR-25&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1/20&lt;/td&gt;
&lt;td&gt;One example omits a classification code it is not exempt from&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Worth internalising if you work with any national CIUS: &lt;strong&gt;the examples and the rules are different artifacts on different release cycles.&lt;/strong&gt; Use the examples as &lt;em&gt;input&lt;/em&gt; to test your reader and writer. Measure the expected validation result; do not assume it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Things a national CIUS will add that EN 16931 does not have
&lt;/h2&gt;

&lt;p&gt;Briefly, because these are the ones that surprise people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A mandatory operator.&lt;/strong&gt; Croatia requires the name and tax number of the &lt;em&gt;person&lt;/em&gt; who issued the invoice, in &lt;code&gt;cac:AccountingSupplierParty/cac:SellerContact&lt;/code&gt;. If your app has
no concept of "who issued this", you now need one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A mandatory issue time.&lt;/strong&gt; EN 16931 has a date only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No empty elements.&lt;/strong&gt; &lt;code&gt;&amp;lt;cbc:Note&amp;gt;&amp;lt;/cbc:Note&amp;gt;&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A classification code on every line&lt;/strong&gt;, from a list of 3,359 permitted values - which
is a &lt;em&gt;subset&lt;/em&gt; 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.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Postscript: what these rules do not check
&lt;/h2&gt;

&lt;p&gt;I found this after publishing, and it belongs here.&lt;/p&gt;

&lt;p&gt;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. &lt;code&gt;204.00&lt;/code&gt; became &lt;code&gt;20400.00&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The validator reported the document as valid.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;HR-BR-54&lt;/code&gt; and &lt;code&gt;HR-BR-55&lt;/code&gt; compare the national VAT total against the document VAT total.&lt;br&gt;
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 &lt;code&gt;BR-CO-17&lt;/code&gt;, an EN 16931 rule, not a national one.&lt;/p&gt;

&lt;p&gt;Two things follow, and they generalise to any national CIUS:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implementing the national rules is not implementing validation.&lt;/strong&gt; The EN 16931 layer runs first and catches a different class of error. Skip it and arithmetic nonsense passes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A validator that accepts your document is telling you less than you think.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;h2&gt;
  
  
  The package
&lt;/h2&gt;

&lt;p&gt;All of the above is in &lt;a href="https://github.com/stboris/laravel-eracun" rel="noopener noreferrer"&gt;&lt;code&gt;stboris/laravel-eracun&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MIT, PHP 8.3+, no framework dependency:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Stboris\Eracun\Validation\Validator&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Validator&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validateFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'invoice.xml'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;brokenCodes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// ["HR-BR-9", "HR-BR-40"]&lt;/span&gt;
&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// ["[HR-BR-9] HR-BT-5: ...", ...]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Violations carry the &lt;strong&gt;official&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Validator&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;coverage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// ['ratio' =&amp;gt; '62/62', 'missing' =&amp;gt; []]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Since publishing, I added a builder, because constructing the document turned out to be the harder half of the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$eracun&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EracunBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'RN-2026-0001'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;issuedAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dueOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$due&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;seller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$seller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;buyer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$buyer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'11111111119'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Boris'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Consulting'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'10'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unitPrice&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'80.00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kpd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'62.10.11'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;Curious whether others implementing a national CIUS have hit the &lt;code&gt;let&lt;/code&gt;-variable problem, or found a cleaner way to stay in sync with the published rules - let me know in the comments.&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>xml</category>
      <category>testing</category>
    </item>
    <item>
      <title>Signing webhook payloads in Laravel - and the mistake that breaks it silently</title>
      <dc:creator>Boris Stiner</dc:creator>
      <pubDate>Tue, 21 Jul 2026 07:56:13 +0000</pubDate>
      <link>https://dev.to/boris-stiner/signing-webhook-payloads-in-laravel-and-the-mistake-that-breaks-it-silently-3o82</link>
      <guid>https://dev.to/boris-stiner/signing-webhook-payloads-in-laravel-and-the-mistake-that-breaks-it-silently-3o82</guid>
      <description>&lt;p&gt;Hi, I'm Boris a PHP developer whose deepest experience is TYPO3, with Laravel and Filament being a newer, growing focus. A lot of that work across both has been building admin tooling and integrations that quietly need to just work: payments, webhooks, notifications. This post is about one small piece of that: signing webhook payloads correctly, and a mistake that's easy to make even once you already know HMAC signing in theory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;If your app sends webhooks to another service - or receives them - you eventually need the receiver to be able to trust that a payload really came from you and wasn't tampered with in transit. The standard answer is HMAC signing: hash the payload with a shared secret, send the hash alongside the request, and have the receiver recompute it and compare.&lt;/p&gt;

&lt;p&gt;Sounds simple. It is simple until you sign the wrong bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signing correctly
&lt;/h2&gt;

&lt;p&gt;The basic recipe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send &lt;code&gt;$signature&lt;/code&gt; in a header (e.g. &lt;code&gt;X-Signature&lt;/code&gt;), and the receiver recomputes the HMAC over the body it received and compares. That part almost everyone gets right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: sign the exact bytes that go over the wire
&lt;/h2&gt;

&lt;p&gt;Here's the version that looks correct but isn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Looks fine, but signs and sends two different serializations&lt;/span&gt;
&lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'event'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'order.created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;42.00&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withHeaders&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'X-Signature'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// this re-serializes $payload internally&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug: you called &lt;code&gt;json_encode($payload)&lt;/code&gt; once, by hand, to compute the signature - and then handed the &lt;em&gt;array&lt;/em&gt; to Laravel's HTTP client, which JSON-encodes it &lt;em&gt;again&lt;/em&gt;, internally, when it builds the actual request body. That's two separate serialization passes. Most of the time they produce identical bytes, so this works in testing and even in production for a long while. Then one day it doesn't - a float gets formatted differently, or some encoding option differs and the signature silently stops matching. It's an infuriating bug to track down because nothing throws an error; the receiver just starts rejecting your webhooks with no obvious cause.&lt;/p&gt;

&lt;p&gt;The fix is to serialize once and send that exact string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Serialize once, sign that string, send that exact string&lt;/span&gt;
&lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withHeaders&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'X-Signature'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;withBody()&lt;/code&gt; sends the raw string as-is, bypassing the client's own array-to-JSON step entirely. Now the bytes you signed and the bytes you sent are guaranteed to be the same bytes, by construction, not by coincidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying on the receiving end
&lt;/h2&gt;

&lt;p&gt;The exact-bytes principle applies just as much on the receiving side, and it's just as easy to get wrong there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getContent&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Signature'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;$request-&amp;gt;getContent()&lt;/code&gt;&lt;/strong&gt;, not &lt;code&gt;$request-&amp;gt;all()&lt;/code&gt; or &lt;code&gt;json_encode($request-&amp;gt;json()-&amp;gt;all())&lt;/code&gt;. The moment you decode the JSON and re-encode it for "readability," you've reintroduced the exact same bug on the receiving side - you're now comparing against a re-serialized version instead of the raw bytes that were actually signed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hash_equals()&lt;/code&gt;&lt;/strong&gt;, not &lt;code&gt;===&lt;/code&gt;. A plain string comparison short-circuits on the first mismatched byte, which leaks timing information an attacker could in theory use to guess the correct signature one byte at a time. &lt;code&gt;hash_equals()&lt;/code&gt; runs in constant time regardless of where the strings diverge.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this came from
&lt;/h2&gt;

&lt;p&gt;I ended up writing this up properly while building the webhook channel for &lt;a href="https://github.com/stboris/filament-outbox" rel="noopener noreferrer"&gt;Filament Outbox&lt;/a&gt; - a small Laravel package with Discord, Slack, Microsoft Teams, and signed-webhook notification channels, built on Laravel's native Notification system (write a normal &lt;code&gt;Notification&lt;/code&gt; class, no new APIs to learn). There's an optional &lt;a href="https://filamentoutbox.com" rel="noopener noreferrer"&gt;Filament v5 admin panel&lt;/a&gt; on top for managing endpoints, browsing send history, and retrying failures - but the free package works standalone in any Laravel app, no Filament required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;If you're signing webhooks anywhere in your stack, it's worth a quick audit for this exact pattern - a hand-rolled &lt;code&gt;json_encode()&lt;/code&gt; followed by handing the array to an HTTP client is an easy thing to write without noticing the double serialization. Curious if others have hit this one, or have a cleaner pattern for it - let me know in the comments.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webhooks</category>
      <category>filament</category>
    </item>
  </channel>
</rss>
