# Laravel package

Source: https://xmlvalid.com/docs/sdks/laravel

> 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](https://xmlvalid.com/docs/sdks/php): 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

```bash
composer require xmlvalid/xmlvalid-laravel
```

Create an API key under [Dashboard → API keys](https://xmlvalid.com/dashboard/api) and add it to `.env`:

```ini
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](#config)).

## Validating uploads

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

```php
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:

```text
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:

```php
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

```php
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:

```php
$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:

```bash
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](#errors), not reported as an invalid field: they are your problem to fix, not the user's.

## Schemas

| Rule | Validates against |
|---|---|
| `ValidXml::schema('ubl-2.1-order')` | A [catalog](https://xmlvalid.com/schemas) 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](https://xmlvalid.com/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`:

```php
$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:

```php
$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:

```php
$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:

```php
$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:

```php
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](https://xmlvalid.com/e-invoicing/germany), [France](https://xmlvalid.com/e-invoicing/france), [Belgium](https://xmlvalid.com/e-invoicing/belgium), [Poland](https://xmlvalid.com/e-invoicing/poland), [Italy](https://xmlvalid.com/e-invoicing/italy), [Spain](https://xmlvalid.com/e-invoicing/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:

```php
$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:

```php
$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](https://xmlvalid.com/docs/sdks/php), configured from `config/xmlvalid.php`. Inject `Xmlvalid\Client` instead if you prefer:

```php
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](https://xmlvalid.com/docs/sdks/php#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

```php
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](#webhooks)) brings the result:

```php
// 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:

```php
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](https://xmlvalid.com/dashboard/webhooks) with the URL `https://your-app.com/xmlvalid/webhook`, and put its signing secret in `.env`:

```ini
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:

| Event | When | Properties |
|---|---|---|
| `ValidationCompleted` | An API validation finished | `$validation`, `$event` |
| `MonitorFailed` | A monitored URL started failing | `$monitor`, `$check`, `$event` |
| `MonitorRecovered` | A monitored URL passes again | `$monitor`, `$check`, `$event` |
| `WebhookReceived` | Any 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.

```php
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

```bash
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:

```php
// 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:

```php
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](https://xmlvalid.com/docs/sdks/php#errors). 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

```ini
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.

|  | Free | Pro |
|---|---|---|
| API validations a month | 100 | 10,000 |
| Largest document | 10 MB | 1 GB |
| Requests a minute | 10 | 120 |
| Saved schemas | 25 | 200 |
| Rules kept with a saved schema, webhooks, report links | No | Yes |

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](https://github.com/xmlvalid/xmlvalid-laravel). The HTTP API underneath is described in the [API reference](https://xmlvalid.com/docs/api).
