Skip to content

Validating e-invoices: KoSIT validator and veraPDF in CI

An e-invoice with a format error is not an e-invoice. Here is how to check every invoice with the official validators, locally and automatically in your CI.

How-to15 Oct 2026 · about 11 minutes

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.

ClassExamplesQuittio levelsConsequence
Format error (no. 6a)XML does not match the schema, a mandatory profile field is missing, the PDF is not PDF/AL1, L2, L4, L6not an e-invoice
Business rule error (no. 6b)Totals don’t add up, Leitweg-ID missingL3, L5still an e-invoice, should be corrected
Content error (no. 35a)A mandatory VAT detail is missing or wrongDE-USTG-…, L7still an e-invoice, may need correcting

Quittio maps its findings to these classes when you need it:

InvoiceService.php
use Quittio\Country\DE\CountryDE;

$classes = (new CountryDE)->errorClassification($result);
// ['format' => [...], 'business_rule' => [...], 'content' => [...]]

The seven validation levels

LevelChecksRuns with
L1Structure and data formats: country and currency codes, decimalsPHP
L2Mandatory and allowed fields of the profilePHP
L3Business rules: EN 16931, XRechnung, Peppol, German VAT ActPHP
L4XML against the XSD (CII D16B, UBL 2.1)PHP (ext-dom)
L5Official Schematron rules of EN 16931, XRechnung and PeppolKoSIT validator (Java)
L6PDF/A-3b conformanceveraPDF (local or Docker)
L7Plausibility: IBAN, BIC, VAT ID, Leitweg-ID, optional VIESPHP

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.

Terminal
php artisan quittio:schemas:download           # into storage/quittio/validators
php artisan quittio:schemas:download --without-peppol

The validator is fastest as a service (daemon) that receives invoices over HTTP. The download command prints the matching start command:

Terminal
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 8081

Alternatively, run it as a Docker container. The Quittio repository contains a Dockerfile for that:

Terminal
docker build -t quittio/kosit -f tools/docker/kosit/Dockerfile .
docker run --rm -p 8081:8080 quittio/kosit

Daemon 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
// 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:

Terminal
php artisan quittio:validate invoice.pdf --profile=en16931 --kosit --verapdf
php artisan quittio:validate invoice.xml --json
Exit codeMeaning
0No errors (warnings don’t count); with --profile the profile matches as well
1Errors found or a different profile than expected
2The 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:

tests/Unit/InvoiceTest.php
$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:

tests/Feature/OfficialValidationTest.php
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:

.github/workflows/tests.yml
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=official

Run 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:

IncomingInvoiceController.php
$report = Quittio::validateFile($content);   // XML or PDF of an incoming invoice

Reading 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.

Frequently asked questions

Is validation without the KoSIT validator enough?

For development usually yes: the PHP rules and the XSD catch most errors. The official Schematron rules only run in the KoSIT validator, though. Switch it on for CI and before going live.

Do I need Java on the production server?

Only if you want to validate with the KoSIT validator in production. Alternatively run it as a separate service or Docker container that Quittio calls over HTTP.

How long does a check take?

The PHP levels take milliseconds. The KoSIT validator as a daemon is fast; in CLI mode a Java process starts per invoice and takes longer.

Does Quittio validate Peppol invoices too?

Yes. The download command compiles the Peppol BIS rules for the KoSIT validator; they run alongside the XRechnung configuration.

What about warnings?

Warnings don’t stop rendering or fail tests. With block_on_warnings you treat them like errors.

Every invoice checked before it goes out.

All validation options are in the documentation.