Laravel package

xmlvalid/xmlvalid-laravel: a validation rule for uploaded XML and e-invoices, a facade, queued validations, webhook events and Artisan commands, for Laravel 12 and 13.

The package is built on the PHP SDK: everything there works here through the facade, and its requests go through Laravel's HTTP client, so they appear in Telescope and Http::fake() answers them in tests.

Install and set up

composer require xmlvalid/xmlvalid-laravel

Create an API key under Dashboard → API keys and add it to .env:

XMLVALID_API_KEY=xv_…

That is all the setup there is. The service provider and the facade are discovered; the config can be published to change the defaults (below).

Validating uploads

ValidXml is a validation rule. Put it after file and the size limit you want:

use Xmlvalid\Laravel\Rules\ValidXml;

$request->validate([
    'invoice' => ['required', 'file', 'max:10240', ValidXml::schema('peppol-bis-3-invoice')],
]);

An invalid document fails the field with its first error, its line and the business rule it broke, and a count of the rest:

The invoice is not valid: Line 139: [BR-CO-16] Amount due for payment (BT-115) = Invoice total amount with VAT (BT-112) - Paid amount (BT-113) + Rounding amount (BT-114).
The invoice has 3 more errors.

The value can be an uploaded file, any file (SplFileInfo) or XML text, so the same rule checks a textarea. In a form request:

use Illuminate\Foundation\Http\FormRequest;
use Xmlvalid\Laravel\Rules\ValidXml;

class StoreInvoiceRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'invoice' => ['required', 'file', 'mimes:xml,pdf,zip', 'max:20480', ValidXml::schema('auto')->errors(5)],
        ];
    }
}

Options

ValidXml::schema('xrechnung-3-ubl')
    ->withRules(resource_path('rules/our-checks.sch'))   // your own Schematron, after the schema check
    ->errors(5)                                          // up to 5 errors as messages (1 by default)
    ->timeout(30);                                       // seconds to wait (config: xmlvalid.timeout)

Keeping the validation

The rule keeps the validation it ran, to store its id or link its report:

$rule = ValidXml::schema('auto');
$request->validate(['invoice' => ['required', 'file', $rule]]);

$invoice = Invoice::create([
    'path' => $request->file('invoice')->store('invoices'),
    'validation_id' => $rule->validation()->id,
    'profile' => $rule->validation()->schema['catalog'] ?? null,   // the profile auto picked
]);

Messages

The messages are translated with the xmlvalid::validation.* keys (invalid, more, not_checked, not_a_document). Publish them to change or translate them:

php artisan vendor:publish --tag=xmlvalid-lang   # lang/vendor/xmlvalid/en/validation.php

Errors of the API itself (a revoked key, an unknown schema, the monthly quota used up) are thrown as exceptions, not reported as an invalid field: they are your problem to fix, not the user's.

Schemas

RuleValidates against
ValidXml::schema('ubl-2.1-order')A catalog schema, with all its include files and, for e-invoice profiles, its business rules
ValidXml::schema('my-12')One of your saved schemas
ValidXml::schema('auto')The catalog profile an e-invoice names
ValidXml::schemaFile(resource_path('schemas/order.xsd'))A single XSD, DTD or RelaxNG file of yours
ValidXml::doctype()The DTD in the document's own <!DOCTYPE>
new ValidXml()Well-formedness only

php artisan xmlvalid:schemas ubl lists slugs, and each schema's page in the catalog shows its own.

A schema that spans several files

schemaFile() sends one file. A schema with xs:include or xs:import is saved once under Dashboard → Schemas: upload its files, or its folder, and mark the main one. Relative paths such as ../common/types.xsd resolve as on disk. It gets an id such as my-12:

$request->validate([
    'order' => ['required', 'file', ValidXml::schema('my-12')],
]);

The same id works in the browser validator and in URL monitors. Free accounts keep up to 25 saved schemas, Pro up to 200. When you change the schema, upload the new files to the same saved schema and the id stays.

Business rules (Schematron)

A schema says which elements may appear; business rules say whether the content makes sense: totals that add up, mandatory fields, code lists, dates. They run after the schema check, only on a document that passed it.

Rules that come with a catalog profile

E-invoice profiles run their official rules for you: en16931-ubl-invoice the EN 16931 rules, peppol-bis-3-invoice EN 16931 and Peppol's, xrechnung-3-ubl EN 16931 and XRechnung's, and so on. Nothing to add: ValidXml::schema('peppol-bis-3-invoice') already fails a document whose totals don't add up, with the rule in the message.

Your own rules

An ISO Schematron file (query binding xslt2 or xslt3) for checks of your own, added to the rule with withRules(). It works with a catalog schema, your own schema or none:

$request->validate([
    'invoice' => ['required', 'file', ValidXml::schema('peppol-bis-3-invoice')->withRules(resource_path('rules/our-checks.sch'))],
]);

Rules split over several files (sch:include, sch:extends, xsl:include) are passed as an array of relative path to file. The paths are the ones the files use to include each other; the main file is the one no other file includes:

$rule = ValidXml::schema('peppol-bis-3-invoice')->withRules([
    'main.sch' => resource_path('rules/main.sch'),
    'parts/totals.sch' => resource_path('rules/parts/totals.sch'),
]);

$request->validate(['invoice' => ['required', 'file', $rule]]);

Rules are compiled on the first request and kept for a week. Rules that don't compile are an error of yours, so the field fails with "could not be validated" and the compiler's message; test them once with php artisan xmlvalid:validate sample.xml --rules=resources/rules/our-checks.sch. On Pro, rules can be kept with a saved schema instead.

Each broken rule

Through the facade, every finding has its rule id and a link to the rule's explanation:

$validation = Xmlvalid::validate(Document::file($path), schema: 'peppol-bis-3-invoice');

foreach ($validation->errors() as $error) {
    if ($error->type === 'schematron') {
        $findings[] = ['rule' => $error->rule, 'line' => $error->line, 'message' => $error->message, 'url' => $error->ruleUrl];
    }
}

E-invoices

One format

When you know the format, pass its profile to the ValidXml rule: it runs the schema and the business rules in one go. For German XRechnung invoices, that is the UBL schema, the EN 16931 rules and the XRechnung rules:

use Xmlvalid\Laravel\Rules\ValidXml;

$request->validate([
    'invoice' => ['required', 'file', 'max:10240', ValidXml::schema('xrechnung-3-ubl')],
]);

The country guides name the right profile for each mandate: Germany, France, Belgium, Poland, Italy, Spain and more.

Whatever suppliers send

auto picks the profile each invoice names (its specification identifier BT-24, or the document element and namespace for national formats). A document it doesn't recognise is checked for well-formedness only, so look at detected when it matters:

$rule = ValidXml::schema('auto');
$request->validate(['invoice' => ['required', 'file', $rule]]);

if (! ($rule->validation()->schema['detected'] ?? false)) {
    return back()->withErrors(['invoice' => 'This is not an e-invoice format we know.']);
}

Factur-X and ZUGFeRD PDFs

A PDF upload goes through the same rule: the XML invoice inside it is validated against the profile it names, and the PDF is checked for the attachment, its relationship and the XMP metadata Factur-X asks for. Allow PDFs in the field (mimes:xml,pdf); the attachment's details are in $rule->validation()->result->pdf.

A batch

A .zip is validated document by document, up to 1,000, each counted as one validation:

$validation = Xmlvalid::validate(Document::file(storage_path('app/imports/march.zip')), schema: 'auto');

['passed' => $passed, 'total' => $total] = $validation->result->documents;
foreach ($validation->result->items as $item) {
    ImportLine::where('file', $item->name)->update(['valid' => $item->valid, 'errors' => $item->errorsTotal]);
}

The facade

Xmlvalid is the PHP client, configured from config/xmlvalid.php. Inject Xmlvalid\Client instead if you prefer:

use Xmlvalid\Document;
use Xmlvalid\Laravel\Facades\Xmlvalid;

$validation = Xmlvalid::validate(Document::file(storage_path('app/invoices/INV-12.xml')), schema: 'peppol-bis-3-invoice');
$validation = Xmlvalid::validate(Document::string($xml), schemaFile: resource_path('schemas/order.xsd'));
$validation = Xmlvalid::validate(Document::url('https://example.com/feed.xml'), schema: 'my-12');
$validation = Xmlvalid::validate(Document::stream(Storage::disk('s3')->readStream($key), basename($key)), schema: 'auto');

$validation->isValid();
foreach ($validation->errors() as $error) {
    $error->line; $error->rule; $error->message; $error->helpUrl;   // (string) $error: "Line 139: [BR-CO-16] …"
}

Xmlvalid::report($validation->id);     // the HTML report
Xmlvalid::share([$validation->id]);    // a public link for 30 days (Pro)
Xmlvalid::usage();                     // this month's validations
Xmlvalid::schemas();                   // your saved schemas, then the catalog

The result's fields are listed in the PHP guide under Reading the result.

Large files and queues

Files and streams are sent as they are read, never held in memory, so a 1 GB export from S3 costs the worker nothing. The rule and validate() wait for the result (up to xmlvalid.timeout, 15 minutes by default), which is fine for a form with a typical invoice but not for a large file in a web request.

Validate in a queued job

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Xmlvalid\Document;
use Xmlvalid\Laravel\Facades\Xmlvalid;

class ValidateImport implements ShouldQueue
{
    use Queueable;

    public int $timeout = 1200;

    public function __construct(public Import $import) {}

    public function handle(): void
    {
        $validation = Xmlvalid::validate(
            Document::stream(Storage::readStream($this->import->path), $this->import->name),
            schema: 'my-12',
        );

        $this->import->update([
            'valid' => $validation->isValid(),
            'errors' => array_map('strval', array_slice($validation->errors(), 0, 50)),
        ]);
    }
}

Give the job a $timeout above the client's, and a retry_after on the queue connection above both.

Submit, and let it tell you

A worker that waits is a worker that does nothing else. submit() answers at once; on Pro, the ValidationCompleted event (webhooks) brings the result:

// in the controller or a job
$validation = Xmlvalid::submit(Document::file($path, $name), schema: 'peppol-bis-3-invoice');
$invoice->update(['validation_id' => $validation->id, 'status' => 'validating']);

// app/Listeners/StoreValidationResult.php
public function handle(ValidationCompleted $completed): void
{
    Invoice::where('validation_id', $completed->validation->id)->update([
        'status' => $completed->validation->isValid() ? 'valid' : 'invalid',
    ]);
}

Or check back later

Without webhooks, a job can look in and release itself until it has finished:

class CheckValidation implements ShouldQueue
{
    use Queueable;

    public int $tries = 120;

    public function __construct(public Invoice $invoice) {}

    public function handle(): void
    {
        $validation = Xmlvalid::validation($this->invoice->validation_id);
        if (! $validation->isFinished()) {
            $this->invoice->update(['progress' => $validation->progress]);
            $this->release(15);

            return;
        }

        $this->invoice->update(['status' => $validation->isValid() ? 'valid' : 'invalid']);
    }
}

Results are kept for 24 hours. Xmlvalid::cancel($id) stops one you no longer need.

Webhooks and events

On Pro, add an endpoint under Dashboard → Webhooks with the URL https://your-app.com/xmlvalid/webhook, and put its signing secret in .env:

XMLVALID_WEBHOOK_SECRET=whsec_…

The package registers the route once the secret is set, outside the web group, so without CSRF or sessions. It checks the signature (and refuses one older than 5 minutes), then dispatches events:

EventWhenProperties
ValidationCompletedAn API validation finished$validation, $event
MonitorFailedA monitored URL started failing$monitor, $check, $event
MonitorRecoveredA monitored URL passes again$monitor, $check, $event
WebhookReceivedAny of them, and test pings$event

They live in Xmlvalid\Laravel\Events, so a listener in app/Listeners with the event as its type is discovered. Make it ShouldQueue for slow work: the endpoint should answer quickly. A delivery that fails is retried with the same $event->id; keep the ids you have handled if a listener must not run twice.

use Xmlvalid\Laravel\Events\MonitorFailed;

class AlertOnFeedFailure
{
    public function handle(MonitorFailed $failed): void
    {
        Notification::route('slack', config('services.slack.alerts'))
            ->notify(new FeedBroken($failed->monitor['url'], $failed->check['errors_total'] ?? null));
    }
}

Artisan

php artisan xmlvalid:validate storage/app/export/*.xml --schema=peppol-bis-3-invoice
php artisan xmlvalid:validate https://example.com/feed.xml --schema-file=resources/feed.xsd --warnings
php artisan xmlvalid:validate invoice.xml --schema=my-12 --rules=resources/rules/our-checks.sch
php artisan xmlvalid:schemas xrechnung

xmlvalid:validate lists the errors and exits with 1 when a document is not valid, so it can stop a deploy or a scheduled export:

// routes/console.php
Schedule::command('xmlvalid:validate storage/app/feeds/products.xml --schema=my-12')->dailyAt('05:00')->emailOutputOnFailure('ops@example.com');

Testing

Requests go through Laravel's HTTP client, so Http::fake() answers them. XmlvalidFake builds the API's answers:

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Xmlvalid\Laravel\Testing\XmlvalidFake;

it('accepts a valid invoice', function () {
    Http::fake(['xmlvalid.com/*' => XmlvalidFake::valid()]);

    $this->post('/invoices', ['invoice' => UploadedFile::fake()->createWithContent('inv.xml', '<Invoice/>')])
        ->assertRedirect();
});

it('shows the broken rule', function () {
    Http::fake(['xmlvalid.com/*' => XmlvalidFake::invalid(
        ['line' => 139, 'rule' => 'BR-CO-16', 'message' => 'Amount due for payment …'],
    )]);

    $this->post('/invoices', ['invoice' => UploadedFile::fake()->createWithContent('inv.xml', '<Invoice/>')])
        ->assertSessionHasErrors(['invoice' => 'The invoice is not valid: Line 139: [BR-CO-16] Amount due for payment …']);

    Http::assertSent(fn ($request) => str_contains($request->url(), '/validations'));
});

XmlvalidFake::validation($valid, $errors) returns the array for an answer of your own (a different status, a PDF result, an archive). Webhook events are plain Laravel events: test listeners with Event::fake() and dispatch them yourself.

Errors

Refusals by the API are thrown, and all extend Xmlvalid\Exception\XmlvalidException; the full table is in the PHP guide. The ones to plan for in an app:

  • QuotaExceededException: this month's validations are used up. Catch it where you validate, or report it to yourself, and decide whether uploads wait or pass unchecked.
  • InvalidRequestException: usually an unknown schema slug; $e->errorCode says which (unknown_schema).
  • TimeoutException: still running after the timeout; $e->validation->id lets you fetch it later.
  • TransportException, ServerException: xmlvalid could not be reached. In a queued job, let the job retry.

A 429 rate_limited is retried for you after the Retry-After the API gives, up to xmlvalid.retries times.

Configuration and limits

XMLVALID_API_KEY=xv_…
XMLVALID_TIMEOUT=900                 # seconds to wait for a result in all
XMLVALID_WAIT=10                     # seconds the API holds a new request (0-60)
XMLVALID_RETRIES=3                   # retries of a 429 rate_limited
XMLVALID_WEBHOOK_SECRET=whsec_…
XMLVALID_WEBHOOK_PATH=xmlvalid/webhook

php artisan vendor:publish --tag=xmlvalid-config copies config/xmlvalid.php, where the webhook route's middleware and the accepted clock skew can be set too.

FreePro
API validations a month10010,000
Largest document10 MB1 GB
Requests a minute10120
Saved schemas25200
Rules kept with a saved schema, webhooks, report linksNoYes

A document in a .zip counts as one validation each. Schematron rules run on documents up to 50 MB, PDFs up to 100 MB, archives up to 1,000 documents.

The source and the issue tracker are on GitHub. The HTTP API underneath is described in the API reference.