# PHP SDK

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

> xmlvalid/xmlvalid-php: validate XML and e-invoices from any PHP 8.2+ project, against the catalog, your own schemas and Schematron business rules.

Using Laravel or Symfony? The [Laravel package](https://xmlvalid.com/docs/sdks/laravel) and the [Symfony bundle](https://xmlvalid.com/docs/sdks/symfony) build on this client and add a validation rule, a constraint and webhook events. Everything on this page works there too.

## Install and set up

```bash
composer require xmlvalid/xmlvalid-php
```

The client sends its requests through any PSR-18 HTTP client. If the project has none, Composer installs one (through `php-http/discovery`); with Guzzle or Symfony HttpClient already there, it uses them. Create an API key under [Dashboard → API keys](https://xmlvalid.com/dashboard/api) and keep it out of the code:

```php
use Xmlvalid\Client;

$xmlvalid = new Client(getenv('XMLVALID_API_KEY'));
```

One client can be shared for the whole process. Its options are listed [below](#options).

## Documents

`validate()` sends a document, waits for the result and returns a `Validation`. The document is a `Xmlvalid\Document`:

```php
use Xmlvalid\Document;

Document::file('invoice.xml');                     // a file, streamed from disk
Document::file('/tmp/upload-7f3a', 'INV-12.xml');  // a file under another name (an upload)
Document::string($xml, 'invoice.xml');             // a string
Document::stream($resource, 'invoice.xml');        // an open stream (fopen, php://temp, S3)
Document::url('https://example.com/feed.xml');     // a public URL that xmlvalid downloads
```

Besides XML, a document can be gzipped (`.gz`), a `.zip` of many documents, an EPUB or a Factur-X / ZUGFeRD PDF. The name appears in results, reports and your history; for a file it defaults to the file's name.

## Schemas

Without a schema, a document is checked for well-formedness only. Choose one of these:

### A catalog schema

The [catalog](https://xmlvalid.com/schemas) has the official schemas, with all their include files, for e-invoices, payments, healthcare, publishing and developer formats. Pass a schema's slug:

```php
$validation = $xmlvalid->validate(Document::file('order.xml'), schema: 'ubl-2.1-order');
$validation = $xmlvalid->validate(Document::file('sitemap.xml'), schema: 'sitemap-0.9');
```

The slug is on each schema's page, or list them from code (your saved schemas first, then the catalog):

```php
foreach ($xmlvalid->schemas() as $schema) {
    echo $schema->schema, '  ', $schema->name, $schema->saved ? ' (saved)' : '', PHP_EOL;
}
```

### Your own schema file

A single XSD, DTD or RelaxNG file (`.rng` or compact `.rnc`) goes with the request. Its language is read from its content:

```php
$validation = $xmlvalid->validate(
    Document::file('order.xml'),
    schemaFile: __DIR__.'/schemas/order.xsd',
);
```

### A schema that spans several files

When a schema has `xs:include` or `xs:import` (or RelaxNG `include`), save it once under [Dashboard → Schemas](https://xmlvalid.com/dashboard/schemas): upload all its files, or its folder, and mark the main one. Relative paths such as `../common/types.xsd` resolve as they do on disk. The saved schema gets an id such as `my-12`, used like a catalog slug:

```php
$validation = $xmlvalid->validate(Document::file('order.xml'), 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.

### The document's own DTD

```php
$validation = $xmlvalid->validate(Document::file('page.xml'), doctype: true);
```

This checks the document against the DTD in its own `<!DOCTYPE>` (the internal subset; external DTD files are not fetched).

## 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 are written in Schematron and run after the schema check, only on a document that passed it.

### Rules that come with a catalog profile

The e-invoice profiles in the catalog 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, `france-ctc-ubl` the French BR-FR rules, and so on. Each finding names its rule:

```php
$validation = $xmlvalid->validate(Document::file('invoice.xml'), schema: 'peppol-bis-3-invoice');

foreach ($validation->errors() as $error) {
    if ($error->type === 'schematron') {
        echo "{$error->rule} on line {$error->line}: {$error->message}", PHP_EOL;
        echo "  What it checks and how to fix it: {$error->ruleUrl}", PHP_EOL;
    }
}
// BR-CO-15 on line 212: Invoice total amount with VAT (BT-112) = Invoice total amount without VAT (BT-109) + Invoice total VAT amount (BT-110).
```

Some rule sets are written in another language: the Croatian rules of `hr-eracun-invoice` report in Croatian, with the English text in `$error->messageEn`. Casting a finding to a string (`(string) $error`) uses the English text when there is one.

### Your own rules

An ISO Schematron file (query binding `xslt2` or `xslt3`) runs after the schema, with a catalog schema, your own schema file or no schema at all:

```php
$validation = $xmlvalid->validate(
    Document::file('invoice.xml'),
    schema: 'peppol-bis-3-invoice',     // its own rules run first
    rules: __DIR__.'/rules/our-checks.sch',
);
```

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

```php
$validation = $xmlvalid->validate(
    Document::file('invoice.xml'),
    rules: [
        'main.sch' => __DIR__.'/rules/main.sch',
        'parts/totals.sch' => __DIR__.'/rules/parts/totals.sch',
        'parts/parties.sch' => __DIR__.'/rules/parts/parties.sch',
    ],
);
```

Rules are compiled on the first request and kept for a week, so later requests with the same files start at once. Rules that don't compile give the outcome `schema` with the compiler's message. On Pro, rules you use all the time can be kept with a [saved schema](#schemas) instead, and then run wherever it is used: here, in the browser validator and in URL monitors.

## E-invoices

### One format

When you know the format, name its profile: it runs the schema and the business rules in one go.

```php
$validation = $xmlvalid->validate(Document::file('invoice.xml'), schema: 'xrechnung-3-ubl');

if ($validation->isValid()) {
    // passes the XRechnung schema and rules; warnings are in $validation->warnings()
}
```

The country guides list 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.

### Invoices of mixed formats

`auto` reads which profile each invoice names (its specification identifier BT-24, or the document element and namespace for national formats such as FatturaPA, KSeF or CFDI) and validates against it:

```php
$validation = $xmlvalid->validate(Document::file($path), schema: 'auto');

$validation->schema['catalog'];   // 'peppol-bis-3-invoice', the profile it picked
$validation->schema['detected'];  // true; false if it didn't recognise the document
```

A document `auto` doesn't recognise is checked for well-formedness only, so look at `detected` before trusting a pass.

### Factur-X, ZUGFeRD and XRechnung PDFs

Send the PDF itself. The XML invoice inside it is taken out and validated against the profile it names (or the schema you pass), and the PDF is checked for what Factur-X asks of it: the attachment, its relationship and the XMP metadata.

```php
$validation = $xmlvalid->validate(Document::file('facture-2026-0412.pdf'));

$validation->result->pdf;   // ['attachment' => 'factur-x.xml', 'guideline' => 'urn:cen.eu:en16931:2017', 'profile' => …]
foreach ($validation->errors() as $error) {
    // PDF findings have the type 'pdf' and a rule such as PDF-AF or PDF-XMP
}
```

It is not a PDF/A conformance check (fonts, colours, structure); use veraPDF for that.

### A batch

Put the invoices in a `.zip`: every XML file and PDF invoice in it is validated, up to 1,000, each against the schema you pass (`auto` works per file).

```php
$validation = $xmlvalid->validate(Document::file('march-invoices.zip'), schema: 'auto');

echo "{$validation->result->documents['passed']} of {$validation->result->documents['total']} passed", PHP_EOL;
foreach ($validation->result->items as $item) {
    if (! $item->valid) {
        echo "{$item->name} ({$item->schema}): {$item->errorsTotal} errors", PHP_EOL;
        foreach ($item->errors as $error) {   // the first 20 of each
            echo '  ', $error, PHP_EOL;
        }
    }
}
```

## Large files and long validations

Files are streamed from disk as they are sent, never read into memory, so a 1 GB document costs the PHP process nothing. The validation itself runs on a queue: `validate()` holds the first request up to 10 seconds (`wait`), then polls, at 1 second, then less often, up to every 5 seconds.

### Waiting longer, or less

```php
use Xmlvalid\Exception\TimeoutException;

try {
    $validation = $xmlvalid->validate(Document::file('export.xml'), schema: 'my-12', timeout: 120);
} catch (TimeoutException $e) {
    $id = $e->validation->id;   // still running on the server; fetch it later
}
```

The default timeout is 15 minutes. A timeout only stops the waiting: the validation goes on, and its result is kept for 24 hours.

### Queue it and come back

In a web request or a job you don't want to block, `submit()` answers at once with the validation's id. Store it, and fetch the result later:

```php
$validation = $xmlvalid->submit(Document::file('export.xml'), schema: 'my-12');
$job->update(['validation_id' => $validation->id]);   // status: queued

// later, in another request or job
$validation = $xmlvalid->validation($job->validation_id);
if ($validation->isFinished()) {
    // done, failed or cancelled
} else {
    echo "{$validation->status}, {$validation->progress}%", PHP_EOL;   // running, 40%
}

// or block until it is done
$validation = $xmlvalid->waitFor($job->validation_id, timeout: 600);

// or give up on it
$xmlvalid->cancel($job->validation_id);
```

On Pro, a [webhook](#webhooks) tells you when it has finished, so nothing has to poll.

## Reading the result

```php
$validation->status;                 // done, failed (on xmlvalid's side) or cancelled
$validation->isValid();              // true for the outcomes valid and warnings
$validation->result->outcome;        // valid, warnings, invalid, schema, input or failed
$validation->result->errorsTotal;    // all of them
$validation->result->errorsTruncated;// true when more than the listed 500
$validation->result->bytes;
$validation->result->durationMs;
$validation->schema;                 // ['kind' => 'xsd', 'catalog' => 'peppol-bis-3-invoice', …]
$validation->error;                  // what went wrong when the status is failed
$validation->raw;                    // the API's JSON, as an array

foreach ($validation->errors() as $error) {   // and $validation->warnings()
    $error->line;      // 0 when about the whole document
    $error->column;    // 0 when unknown
    $error->level;     // fatal, error or warning
    $error->type;      // syntax, xsd, relaxng, dtd, schematron, pdf, …
    $error->message;
    $error->rule;      // BR-CO-15 for a business rule, else null
    $error->messageEn; // the English text of a rule written in another language
    $error->ruleUrl;   // the rule's page on xmlvalid.com
    $error->helpUrl;   // what this kind of message means and how to fix it
}
```

The outcomes: `invalid` means the document broke the schema or the rules; `schema` that the schema itself could not be used (an XSD that doesn't compile, a missing include); `input` that the document could not be read (not XML, a broken archive); `failed` that something broke on our side.

## Reports and links

```php
$html = $xmlvalid->report($validation->id);   // the HTML report: verdict, schema, every finding
file_put_contents('report.html', $html);       // open it and print to PDF

$link = $xmlvalid->share([$validation->id, $other->id]);   // Pro: one public page for several
echo $link->url, ' until ', $link->expiresAt->format('Y-m-d'), PHP_EOL;   // 30 days

$usage = $xmlvalid->usage();
echo "{$usage->used} of {$usage->limit} this month, {$usage->remaining()} left", PHP_EOL;
```

## Errors and retries

A finding in the document is not an exception: it is in the result. Exceptions are for requests that could not be done. All extend `Xmlvalid\Exception\XmlvalidException`; refusals by the API are `ApiException`s with `$statusCode`, `$errorCode` and, for 422, `$fields`:

| Exception | When |
|---|---|
| `AuthenticationException` | 401: no key, or a revoked one |
| `PlanLimitException` | 403: not in your plan (report links on Free) |
| `NotFoundException` | 404: no such validation, or it expired after 24 hours |
| `NotFinishedException` | 409: the report of a validation still running |
| `DocumentTooLargeException` | 413: over your plan's size limit |
| `InvalidRequestException` | 422: an unknown schema, a bad URL, a schema file that isn't one |
| `QuotaExceededException` | 429: this month's validations are used up |
| `RateLimitedException` | 429: too many requests a minute, still so after the retries |
| `ServerException` | 5xx |
| `TransportException` | xmlvalid could not be reached, or answered with something unreadable |
| `TimeoutException` | Still running after the timeout; `$e->validation` has it |

```php
use Xmlvalid\Exception\InvalidRequestException;
use Xmlvalid\Exception\QuotaExceededException;

try {
    $validation = $xmlvalid->validate(Document::file('invoice.xml'), schema: $slug);
} catch (InvalidRequestException $e) {
    // $e->errorCode: unknown_schema; $e->fields: ['schema' => ['…']]
} catch (QuotaExceededException $e) {
    // wait for next month, or upgrade
}
```

A request refused with `429 rate_limited` is sent again after the `Retry-After` the API gives, up to 3 times (`maxRetries`). A document from a non-seekable stream can't be sent twice, so it is not retried.

## Webhooks

On Pro, add an endpoint under [Dashboard → Webhooks](https://xmlvalid.com/dashboard/webhooks). xmlvalid then posts an event when an API validation finishes (`validation.completed`), when a URL monitor starts failing or passes again (`monitor.failed`, `monitor.recovered`), and a `ping` when you test the endpoint. Check its signature with the endpoint's secret and the raw body:

```php
use Xmlvalid\Exception\SignatureVerificationException;
use Xmlvalid\Model\Event;
use Xmlvalid\Webhook;

try {
    $event = Webhook::verify(
        file_get_contents('php://input'),
        $_SERVER['HTTP_XMLVALID_SIGNATURE'] ?? '',
        getenv('XMLVALID_WEBHOOK_SECRET'),
    );
} catch (SignatureVerificationException) {
    http_response_code(403);
    exit;
}

if ($event->type === Event::VALIDATION_COMPLETED) {
    $validation = $event->validation();   // the same object validation() returns
} elseif ($event->type === Event::MONITOR_FAILED) {
    $monitor = $event->data['monitor'];   // id, name, url, schema, …
    $check = $event->data['check'];       // valid, errors_total, message, …
}
http_response_code(204);
```

A signature older than 5 minutes is refused, against replays (`tolerance`, in seconds). A delivery that fails is retried with the same `$event->id`, so keep the ids you have handled.

## Testing

Pass a PSR-18 client of your own to answer without the network. With Guzzle's mock handler:

```php
use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;

$mock = new MockHandler([
    new Response(200, ['Content-Type' => 'application/json'], json_encode([
        'id' => 'val_test', 'status' => 'done',
        'result' => ['valid' => false, 'outcome' => 'invalid', 'errors_total' => 1, 'errors' => [
            ['line' => 12, 'level' => 'error', 'type' => 'schematron', 'rule' => 'BR-CO-15', 'message' => 'Totals'],
        ]],
    ])),
]);
$xmlvalid = new Client('xv_test', httpClient: new Guzzle(['handler' => HandlerStack::create($mock)]));
```

## Options and limits

```php
new Client(
    apiKey: 'xv_…',
    baseUrl: 'https://xmlvalid.com/api/v1',
    httpClient: $psr18Client,   // with requestFactory: and streamFactory: (PSR-17), else discovered
    timeout: 900,               // seconds validate() waits in all
    wait: 10,                   // seconds the API holds a new validation's request (0-60)
    maxRetries: 3,              // retries of a 429 rate_limited
);
```

|  | 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, the changelog and the issue tracker are on [GitHub](https://github.com/xmlvalid/xmlvalid-php). The HTTP API underneath is described in the [API reference](https://xmlvalid.com/docs/api).
