stboris / laravel-eracun
Read, build and validate Croatian eRačun (HR CIUS / EXT 2025) UBL documents in PHP.
Requires
- php: ^8.3
- ext-dom: *
- ext-libxml: *
Requires (Dev)
- illuminate/support: ^11.0|^12.0|^13.0
- pestphp/pest: ^3.0
Suggests
- dompdf/dompdf: Required by DompdfRenderer to produce PDFs.
- illuminate/support: Required for the Laravel service provider, config and validation rules.
README
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::Pdv25
Typed objects throughout. No arrays of magic string keys.
The Croatian bits that are easy to get wrong
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 base
Amounts 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-tripping
The 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>
Building
use Stboris\Eracun\Writing\UblWriter; $xml = (new UblWriter())->toXml($eracun);
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.
Validating
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.
What this validator does and does not check
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:
Validator::default()->coverage(); // ['ratio' => '62/62', 'implemented' => [...], 'missing' => []]
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.
Verified against the official Schematron
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:
tools/schematron-verify.sh # runs the official .sch via Saxon in Docker php tools/compare-with-schematron.php # 20 agreed, 0 disagreed
Both 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.
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.
The official examples do not pass
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.
Rendering
use Stboris\Eracun\Rendering\EracunRenderer; echo (new EracunRenderer())->render($eracun); // Croatian echo (new EracunRenderer())->render($eracun, 'en'); // English
A 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/dompdf
use Stboris\Eracun\Rendering\DompdfRenderer; file_put_contents('racun.pdf', (new DompdfRenderer())->render($eracun));
Laravel
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-lang
illuminate/support is a dev dependency, not a requirement: the core is plain PHP and
installs anywhere.
Documentation
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.
Scope
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.
Status
Reader, writer, renderer, code lists and all 62 published business rules are done, 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.
225 tests, 608 assertions.
Licence
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.