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
| Rule | Validates 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:
| 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.
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->errorCodesays which (unknown_schema).TimeoutException: still running after the timeout;$e->validation->idlets 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.
| 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. The HTTP API underneath is described in the API reference.