In short
Quittio validates every invoice on seven levels. Five of them run in PHP without any installation. The two official validators, the KoSIT validator (Schematron rules of EN 16931, XRechnung and Peppol) and veraPDF (PDF/A-3b), are installed with php artisan quittio:schemas:download or via Docker and switched on in validation.levels. In CI they run as a separate test group.
Why validate?
The German Federal Ministry of Finance letter of 15 October 2025 groups errors in e-invoices into three classes. The key consequence: a format error means the invoice no longer counts as an e-invoice but as an “other invoice”. Once the transition periods end, that is no longer allowed for domestic B2B invoices.
| Class | Examples | Quittio levels | Consequence |
|---|---|---|---|
| Format error (no. 6a) | XML does not match the schema, a mandatory profile field is missing, the PDF is not PDF/A | L1, L2, L4, L6 | not an e-invoice |
| Business rule error (no. 6b) | Totals don’t add up, Leitweg-ID missing | L3, L5 | still an e-invoice, should be corrected |
| Content error (no. 35a) | A mandatory VAT detail is missing or wrong | DE-USTG-…, L7 | still an e-invoice, may need correcting |
Quittio maps its findings to these classes when you need it:
use Quittio\Country\DE\CountryDE;
$classes = (new CountryDE)->errorClassification($result);
// ['format' => [...], 'business_rule' => [...], 'content' => [...]]The seven validation levels
| Level | Checks | Runs with |
|---|---|---|
| L1 | Structure and data formats: country and currency codes, decimals | PHP |
| L2 | Mandatory and allowed fields of the profile | PHP |
| L3 | Business rules: EN 16931, XRechnung, Peppol, German VAT Act | PHP |
| L4 | XML against the XSD (CII D16B, UBL 2.1) | PHP (ext-dom) |
| L5 | Official Schematron rules of EN 16931, XRechnung and Peppol | KoSIT validator (Java) |
| L6 | PDF/A-3b conformance | veraPDF (local or Docker) |
| L7 | Plausibility: IBAN, BIC, VAT ID, Leitweg-ID, optional VIES | PHP |
Without further setup, the PHP levels and the XSD run (['php', 'xsd']). That catches most errors. The official rules, however, are XSLT 2.0, which PHP cannot execute. For full certainty you need the KoSIT validator and veraPDF.
Setting up the KoSIT validator
KoSIT is the German agency that publishes XRechnung. Its validator is the official tool that public portals use too. One command downloads everything: the validator, the XRechnung configuration and the compiled Peppol rules.
php artisan quittio:schemas:download # into storage/quittio/validators
php artisan quittio:schemas:download --without-peppolThe validator is fastest as a service (daemon) that receives invoices over HTTP. The download command prints the matching start command:
cd storage/quittio/validators
java -jar validator.jar \
-s xrechnung/scenarios.xml -r xrechnung \
-s ../../../vendor/quittio/core/resources/kosit/peppol-scenarios.xml -r . \
-D -P 8081Alternatively, run it as a Docker container. The Quittio repository contains a Dockerfile for that:
docker build -t quittio/kosit -f tools/docker/kosit/Dockerfile .
docker run --rm -p 8081:8080 quittio/kositDaemon or CLI?
In cli mode, Quittio starts a separate Java process for every invoice. That is simple but slow. For many invoices, use daemon mode.
Setting up veraPDF
veraPDF checks whether the PDF is a valid PDF/A-3b, the archival form that ZUGFeRD requires. The easiest way is Docker: docker pull verapdf/cli. Alternatively, install veraPDF locally and use cli mode.
Switching the levels on
Add kosit and verapdf to the validation levels. From then on every render() also checks with the official validators:
// config/quittio.php
'validation' => [
'levels' => ['php', 'xsd', 'kosit', 'verapdf'],
'drivers' => [
'kosit' => ['mode' => 'daemon', 'url' => env('QUITTIO_KOSIT_URL', 'http://127.0.0.1:8081')], // daemon address
'verapdf' => ['mode' => 'docker', 'image' => 'verapdf/cli'], // or mode cli with a local installation
],
],When a validator is missing
If the KoSIT validator is unreachable or veraPDF is not installed, Quittio reports KOSIT-UNAVAILABLE or VERAPDF-UNAVAILABLE as an error. With fail_on_unavailable => false it becomes a warning. In production you usually don’t want that.
Checking a single file
For an invoice you received or produced, there is a command. It shows number, syntax, profile, the levels checked and a table of all findings:
php artisan quittio:validate invoice.pdf --profile=en16931 --kosit --verapdf
php artisan quittio:validate invoice.xml --json| Exit code | Meaning |
|---|---|
0 | No errors (warnings don’t count); with --profile the profile matches as well |
1 | Errors found or a different profile than expected |
2 | The file could not be checked: missing, not an e-invoice or unknown profile |
Checking in tests
check() shows all findings of an invoice without producing a PDF or throwing. That suits fast tests:
$report = Quittio::make($invoice)->format('xrechnung-cii')->check();
$report->isValid();
$report->errors();For finished files there is the assertion assertValidEInvoice() from the InteractsWithQuittio trait. It checks with all enabled levels. Because the official validators need Java and Docker, they run in a separate test group:
use Quittio\Core\Data\InvoiceData;
use Quittio\Core\Validation\Drivers\KositArtifacts;
use Quittio\Laravel\Facades\Quittio;
function useOfficialValidators(): void
{
$root = storage_path('quittio/validators'); // php artisan quittio:schemas:download
config()->set('quittio.validation.levels', ['php', 'xsd', 'kosit', 'verapdf']);
config()->set('quittio.validation.drivers.kosit', [
'mode' => 'cli',
'jar' => $root.'/validator.jar',
'scenarios' => $root.'/xrechnung/scenarios.xml',
'repository' => $root.'/xrechnung',
'configurations' => [['scenarios' => KositArtifacts::peppolScenarios(), 'repository' => $root]],
'timeout' => 120,
]);
config()->set('quittio.validation.drivers.verapdf', ['mode' => 'docker', 'image' => 'verapdf/cli']);
Quittio::flush();
}
it('produces valid ZUGFeRD and XRechnung', function () {
useOfficialValidators();
$invoice = InvoiceData::factory()->state(fn ($b) => $b->buyerReference('04011000-12345-03'))->make();
$result = Quittio::make($invoice)->formats(['zugferd', 'xrechnung-cii'])->render();
$this->assertValidEInvoice($result->pdf(), 'en16931');
$this->assertValidEInvoice($result->as('xrechnung-cii')->xml(), 'xrechnung');
})->group('official');Automating in GitHub Actions
A dedicated job installs Java, downloads the validators (cached), pulls the veraPDF image and runs only the official group:
official-validation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php: '8.4'
extensions: dom, libxml, mbstring, xmlwriter, zlib, intl, gd, bcmath, zip
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '21'
- uses: actions/cache@v6
with:
path: storage/quittio/validators
key: quittio-validators-${{ hashFiles('composer.lock') }}
- run: composer install --no-interaction --no-progress
- run: php artisan quittio:schemas:download # keeps existing files
- run: docker pull verapdf/cli
- run: vendor/bin/pest --group=officialRun the fast test job with --exclude-group=official. Your regular tests stay fast and the official checks still run on every push.
Checking incoming invoices
You can apply the same levels to invoices you receive, for example before importing them into accounting:
$report = Quittio::validateFile($content); // XML or PDF of an incoming invoiceReading the messages
Every finding names the rule code, field, message and a hint for the fix. The prefix shows the origin: BR- (EN 16931), BR-DE- (XRechnung), PEPPOL- (Peppol), DE-USTG- (German VAT Act), QUITTIO- (Quittio), PDFA- (veraPDF). One missing value can be reported by several rules; fix it and they all go away.