Read, build and validate Croatian eRačun documents - UBL 2.1 under HR CIUS / EXT 2025.
use Stboris\Eracun\Reading\UblReader;
$eracun = (new UblReader())->fromFile('racun.xml');
$eracun->number; // "1-P1-1"
$eracun->seller->name; // "TVRTKA A d.o.o."
$eracun->seller->oib(); // "12345678901"
$eracun->operator->name; // "Operater1" (HR-BT-4, no EN 16931 equivalent)
$eracun->totals->payableAmount; // Amount("125.00", "EUR")
$eracun->kpdCodes(); // ["62.20.20"]
$eracun->lines[0]->item->taxCategory->hrCategory; // HrVatCategory::Pdv25Typed objects throughout. No arrays of magic string keys.
One HR marker fixes three codes. HR:POVNAK is not just a label - it pins the
UNTDID 5305 category to E, the extension category (HR-BT-18) to O, and the tax
scheme to OTH. Getting them out of step is a rejection. The mapping is table HR-TB-2
of the specification, encoded once:
HrVatCategory::Povnak->vatCategory(); // VatCategory::Exempt → BT-118
HrVatCategory::Povnak->hrCategoryCode(); // "O" → HR-BT-18
HrVatCategory::Povnak->taxScheme(); // "OTH" → UNTDID 5153
HrVatCategory::Povnak->isPassThrough(); // true - stays out of the VAT baseAmounts keep their literal text. HR-BR-56 limits amounts to 30 characters and 10
decimal places, and it measures the written form - so 100.000000 and 100.00 are not
interchangeable. Parsing to float on the way in destroys the only thing that rule can
be checked against, and puts binary rounding error into the reconciliation rules.
$price = $eracun->lines[0]->priceAmount;
$price->value; // "100.000000" - exactly as written
$price->decimals(); // 6
$price->toFloat(); // 100.0 - for display, never for round-trippingThe extension is a second tax tree. HR CIUS is not only a CIUS. ext-2025 adds
hrextac:HRFISK20Data - a parallel tax-total structure carried in ext:UBLExtensions
- required whenever amounts sit outside the ordinary VAT base, including every seller outside the VAT system.
$eracun->hasHrExtension(); // true
$eracun->hrData->outOfScopeOfVatAmount; // Amount("0.00", "EUR")
$eracun->hrData->taxSubtotals; // list<TaxSubtotal>Start from EracunBuilder. It works out the VAT breakdown, the monetary totals and the
HR extension from your lines, which is the part people get wrong - six of the published
rules check nothing but that reconciliation.
use Stboris\Eracun\Building\EracunBuilder;
use Stboris\Eracun\Enums\HrVatCategory;
$eracun = EracunBuilder::invoice('RN-2026-0001')
->issuedAt(new DateTimeImmutable('2026-08-01 10:15:00'))
->dueOn(new DateTimeImmutable('2026-08-31'))
->seller($seller)
->buyer($buyer)
->operator('11111111119', 'Boris') // HR-BT-4/5, mandatory
->payment(code: '30', iban: 'HR1210010051863000160')
->line('Programiranje', quantity: '10', unitPrice: '80.00', kpd: '62.10.11', unit: 'HUR')
->line('Povratna naknada', quantity: '20', unitPrice: '0.50',
vat: HrVatCategory::Povnak, kpd: '38.11.11')
->charge('12.00', 'Dostava')
->build();
$xml = (new UblWriter())->toXml($eracun);That POVNAK line makes the document need the HR extension. The builder notices, emits it,
and reconciles HR-BT-23, HR-BT-24 and HR-BT-25 against the ordinary totals - so
HR-BR-26, 27, 30, 32, 54 and 55 hold by construction rather than by luck.
It guarantees the sums, not the content: a missing operator or an invalid KPD code is still a validation failure, so run the validator on the result.
UblWriter is also usable directly when you already have an Eracun - from the reader,
or assembled by hand.
Round-tripping is exact: read a document, write it again, and the result parses back
to an equal object. Amounts are written from their literal strings, never reformatted,
so 100.000000 does not silently become 100.00.
Two things the writer takes care of that are easy to get wrong by hand:
Nothing empty is ever emitted. HR-BR-33 forbids empty elements, so every value
goes through a helper that writes nothing rather than <cbc:Note/>, and containers that
end up childless are removed. The one exception is the signature placeholder, which the
rule explicitly exempts - pass new UblWriter(includeSignaturePlaceholder: false) to
drop it.
Element order follows the XSD, not the examples. UBL types are sequences, so a field
in the wrong position is invalid before any business rule is reached. Credit notes are
not just invoices with a different element name: CreditNoteType has no cbc:DueDate
at all and puts cbc:TaxPointDate before the type code. A credit note that needs a
due date has to carry it in cac:PaymentMeans/cbc:PaymentDueDate.
Four of the official reference invoices ship with the package, so there is something to try it against immediately:
$path = 'vendor/stboris/laravel-eracun/resources/examples/invoice-standard-vat.xml';
$eracun = (new UblReader())->fromFile($path);invoice-standard-vat, invoice-outside-vat-system (the 1 January 2027 case, carries the
extension), credit-note, and invoice-with-povnak. See
resources/examples/README.md - and note they do not pass
validation, for documented historical reasons.
use Stboris\Eracun\Validation\Validator;
$result = Validator::default()->validateFile('racun.xml');
$result->isValid(); // false
$result->brokenCodes(); // ["HR-BR-9", "HR-BR-40"]
$result->messages(); // ["[HR-BR-9] HR-BT-5: Račun mora sadržavati ispravan OIB operatera.", …]Violations carry the official rule identifier, so a message from this package matches the code in a rejection report from a posrednik or from the Porezna uprava validator. Every message exists in Croatian and English.
It is a business-rule validator, not a conformance validator. Passing it does not guarantee a document will be accepted.
Rather than describing that in prose, the package reports it, per layer:
Validator::default()->coverage();
// [
// 'checks' => ['national'],
// 'assumes' => ['xsd', 'en16931', 'cius'],
// 'caveat' => 'A clean result means the document satisfies the layers under "checks" …',
// 'layers' => [
// 'xsd' => ['status' => 'not-implemented', …],
// 'en16931' => ['status' => 'not-implemented', …],
// 'cius' => ['status' => 'not-implemented', …],
// 'national' => ['status' => 'complete', 'ratio' => '62/62', 'missing' => [], …],
// ],
// ]There is deliberately no single number. 62/62 was true and still answered the wrong
question, because three layers sit underneath it that this package does not implement.
They are reported as not-implemented rather than left out, and a test fails if any of
them is ever quietly dropped.
Within the national layer, all 62 rules published in the official Schematron (version 2026-03-13) are implemented, and a test asserts that every code the package claims actually exists in that file, so the two cannot drift apart.
Those 62 are the Croatian overlay, and only the Croatian overlay. Every code this
package implements is an HR-BR-*. It does not check the EN 16931 base rules - no
BR-*, no BR-CO-*, no BR-S-* - because HR CIUS is a layer on top of EN 16931 rather
than a replacement for it. A clean result therefore means Croatia-valid on top of a
document assumed to be EN 16931-valid already, which is a narrower claim than it sounds.
That distinction has teeth. HR-BR-54 and HR-BR-55 compare the national VAT total
against the document VAT total, so a document whose totals are both wrong by the same
factor satisfies both rules while being arithmetically nonsense; the rule that catches it
is BR-CO-17, which lives in the base layer. If you need the base rules too, run the
EN 16931 Schematron alongside this package - the two are complementary, not alternatives.
Thanks to Tobias for pressing on this distinction; the coverage report was accurate and the framing around it was not.
The Schematron is a pinned dependency that happens to be a file rather than a Composer
package, so it gets the same treatment one would get. resources/rulesets.json records
the issuing authority, the version, the upstream URL and a SHA-256 of every .sch file
the rules were transcribed from, and the package will tell you:
Validator::rulesetVersion();
// [['id' => 'hr-cius-ext-2025', 'authority' => 'Ministarstvo financija - Porezna uprava',
// 'version' => '2026-03-13', 'inForceFrom' => '2026-03-15', 'files' => [...]]]A test hashes the vendored files against that manifest, so the artifact cannot change
under the rules written against it without the build going red. The .sch files
themselves are export-ignored and are not part of a Composer install; the manifest is,
which is why an installed copy can still answer what it was built from.
Noticing that the authority has published something new is deliberately not a test. That fails for reasons outside this repository - a re-upload, a moved URL, a restructured portal - and a check that goes red for reasons nobody can fix is a check people learn to ignore. It belongs in a scheduled job that opens an issue.
The rules are reimplemented in PHP, so the question that matters is whether they agree with the artifact they were copied from. They do, and it is checkable:
vendor/bin/schematron-diff run hr-cius-ext-2025
# 20 agreed, 0 disagreedBoth the 20 reference invoices and the same documents after a round-trip through
UblWriter produce byte-identical violation sets under the official Schematron and
under this package. The harness is a verification tool, not a dependency - nothing in
the package needs Java or Docker.
The harness is stboris/schematron-diff, a
dev dependency rather than something in this repository, because the one part of it that
is not Croatian had no business living inside a Croatian package. What lives here is
schematron-diff.json and a four-method adapter in tests/Harness.
It is still not a conformance validator: the XSD structural layer is separate, and agreement on 40 documents is evidence rather than proof. One implementation detail is worth knowing:
HR-BR-33(no empty XML elements) is about the serialised form and cannot be expressed against the object model. It runs undervalidateXml()andvalidateFile(), and appears inskippedCodeswhen validating an in-memory document.
Since the ruleset is a parameter, the obvious next question was whether the layer underneath holds up. The harness ships with the EN 16931 UBL Schematron 1.3.16 and a corpus generated to break one rule per document, so anyone can ask it:
vendor/bin/schematron-diff run en16931-ublTwo findings, and the second is the one that matters.
Where a candidate base layer implements a rule, it tends to be right. Zero disagreements. That is a real result for hand-transcribed rules, and it is the outcome the harness exists to be able to state rather than assume.
Coverage is a different question from correctness. The Schematron carries 223
business rules and the arithmetic reconciliation family, BR-CO-*, is the part that
catches a VAT figure scaled by the wrong factor. An implementation can agree perfectly
with the Schematron on every rule it has and still not have that one.
This is what "agreement is not completeness" looks like when it is measured instead of
asserted. A rule neither implementation has cannot show up as a disagreement, which is
also why BR-CO-25 and BR-CO-27 are invisible here: they are in the text of
EN 16931-1:2017+A1:2019 and absent from the published Schematron, so nothing in this
pipeline can see them. The BR-CO- range in 1.3.16 runs 03 to 26 with 25 missing.
Worth knowing before you use them as fixtures. All 20 reference invoices published by the Porezna uprava fail the current rule set, for reasons that are entirely historical:
| Rule | Files | Why |
|---|---|---|
HR-BR-40 |
20/20 | Every example is dated 2025; the rule requires issue dates from 2026-01-01 |
HR-BR-9 |
20/20 | The seller placeholder 12345678901 fails the OIB checksum |
HR-BR-53 |
19/20 | Same placeholder as the seller tax identifier |
HR-BR-25 |
1/20 | The leasing example (type 394) carries no KPD code and 394 is not exempt |
The examples were published 12.12.2025; the Schematron was revised 13.03.2026. Do not write a test that asserts they are all clean.
use Stboris\Eracun\Rendering\EracunRenderer;
echo (new EracunRenderer())->render($eracun); // Croatian
echo (new EracunRenderer())->render($eracun, 'en'); // EnglishA UBL invoice has no canonical visual form - the XML is the invoice and any rendering is a vizualizacija. The default template says so in its footer, which matters: a printed rendering is not the legal document.
The template is plain PHP, not Blade, so the core works outside Laravel. Point the renderer at your own file, or publish the shipped one and edit it.
PDF is an interface with one optional implementation, because a PDF engine is a heavy and opinionated dependency that does not belong in a library about XML:
composer require dompdf/dompdfuse Stboris\Eracun\Rendering\DompdfRenderer;
file_put_contents('racun.pdf', (new DompdfRenderer())->render($eracun));Auto-discovered. Nothing to register.
$request->validate([
'oib' => ['required', new ValidOib()],
'kpd' => ['required', new ValidKpd()],
'racun' => ['required', 'file', new ValidEracunXml()],
]);ValidEracunXml checks only that the document is readable by default, because a
received invoice that breaks a rule still has to be accepted, stored, and then refused
through the odbijanje workflow. Pass enforceRules: true when validating something you
are about to issue - you then get one message per violation, each carrying its official
rule code.
php artisan vendor:publish --tag=eracun-config
php artisan vendor:publish --tag=eracun-views
php artisan vendor:publish --tag=eracun-langilluminate/support is a dev dependency, not a requirement: the core is plain PHP and
installs anywhere.
Field reference - every HR CIUS field, its business term, cardinality, and the PHP property that carries it. Including the HR-TB-2 marker table, which is the single most error-prone part of the specification.
This package is about the eRačun document, not about transmitting it.
In scope: reading, building, rendering, validating, and the code lists.
Out of scope, permanently: fiskalizacija to the Porezna uprava, transmission through an informacijski posrednik, B2C fiskalizacija, eIzvještavanje. Those need a certificate or a commercial contract.
Transport is provided as an interface with no implementations, and there never will
be any. It exists so an adapter can be added without forking. Writing one against a
posrednik API with your own credentials is ordinary integration work and needs nothing
from this package beyond valid XML.
Stable. Builder, reader, writer, renderer, code lists and all 62 published business rules, covered against the 20 official reference invoices: every one round-trips to an equal object, every generated document validates against the official UBL 2.1 XSD, and rewriting never changes which rules a document breaks. The validator is verified against the official Schematron itself - 20 agreed, 0 disagreed.
254 tests, 712 assertions.
stboris/schematron-diff - the verification harness this package uses, as its own package. It diffs a PHP implementation against the Schematron it was transcribed from, for any country's ruleset, and ships with the EN 16931 Schematron and a corpus of deliberately broken documents so it does something the moment you install it.
It came out of this repository because the one part of it that was not Croatian had no business living inside a Croatian package. If you are implementing another country's CIUS, that is the piece worth taking.
MIT - see LICENSE.
The licence covers the source code. The official artifacts under research/ remain the
work of their publishers under their own terms - see NOTICE and
research/README.md.
This package processes eRačun documents; it does not make anyone compliant with the Zakon o fiskalizaciji. No certificates are handled, stored or transmitted, and no connection is made to any government system.
Security reports: see SECURITY.md.