stboris/laravel-eracun

Read, build and validate Croatian eRačun (HR CIUS / EXT 2025) UBL documents in PHP.

Maintainers

Package info

github.com/stboris/laravel-eracun

pkg:composer/stboris/laravel-eracun

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-07-28 15:01 UTC

This package is auto-updated.

Last update: 2026-07-28 15:46:51 UTC


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 under validateXml() and validateFile(), and appears in skippedCodes when 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.