The bundle is built on the PHP SDK: everything there works here through the Xmlvalid\Client service, and its requests go through Symfony's HTTP client, so the profiler shows them and MockHttpClient answers them in tests.
Install and set up
composer require xmlvalid/xmlvalid-symfony
With Flex the bundle is enabled for you; without it, add Xmlvalid\Symfony\XmlvalidBundle::class => ['all' => true] to config/bundles.php. Create an API key under Dashboard → API keys and add it to .env.local (or a secret):
XMLVALID_API_KEY=xv_…
The key is read from XMLVALID_API_KEY by default; the other settings are optional.
Validating uploads
ValidXml is a validator constraint, for a DTO, an entity or a form field:
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Validator\Constraints as Assert;
use Xmlvalid\Symfony\Validator\ValidXml;
final class InvoiceUpload
{
#[Assert\NotNull]
#[Assert\File(maxSize: '20M', extensions: ['xml', 'pdf', 'zip'])]
#[ValidXml(schema: 'peppol-bis-3-invoice')]
public ?UploadedFile $invoice = null;
}
An invalid document gets a violation with its first error, its line and the business rule it broke, and one with a count of the rest:
This document 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).
This document has 3 more errors.
The value can be an uploaded file, any file (SplFileInfo) or XML text. Empty values pass, so add NotNull or NotBlank to a required field. In a form type, and with #[MapUploadedFile] (Symfony 7.1+):
$builder->add('invoice', FileType::class, [
'constraints' => [new Assert\NotNull(), new ValidXml(schema: 'auto', errors: 5)],
]);
#[Route('/invoices', methods: ['POST'])]
public function store(
#[MapUploadedFile([new Assert\File(maxSize: '20M'), new ValidXml(schema: 'auto')])] UploadedFile $invoice,
): Response {
// a failed constraint answers 422 before this runs
}
Options
#[ValidXml(
schema: 'xrechnung-3-ubl',
rules: 'config/rules/our-checks.sch', // your own Schematron, after the schema check
errors: 5, // up to 5 errors as violations (1 by default)
timeout: 30, // seconds to wait (the bundle's timeout by default)
message: 'The invoice is not valid: {{ error }}',
)]
Relative schemaFile and rules paths are files of the project (%kernel.project_dir%), so they fit in an attribute. The messages are message, moreMessage, notCheckedMessage and notADocumentMessage, translated through the validators domain like any constraint's. The violation codes are ValidXml::INVALID_ERROR, MORE_ERRORS_ERROR, NOT_CHECKED_ERROR and NOT_A_DOCUMENT_ERROR.
Keeping the validation
To store the validation's id or link its report, autowire Xmlvalid\Symfony\Validator\ValidXmlValidator; lastValidation() returns the last one it ran:
$errors = $validator->validate($upload);
if (count($errors) === 0) {
$invoice->setValidationId($xmlvalidValidator->lastValidation()?->id);
}
Errors of the API itself (a revoked key, an unknown schema, the monthly quota used up) are thrown as exceptions, not reported as violations: they are yours to fix, not the user's.
Schemas
| Option | Validates against |
|---|---|
schema: 'ubl-2.1-order' | A catalog schema, with all its include files and, for e-invoice profiles, its business rules |
schema: 'my-12' | One of your saved schemas |
schema: 'auto' | The catalog profile an e-invoice names |
schemaFile: 'config/schemas/order.xsd' | A single XSD, DTD or RelaxNG file of yours |
doctype: true | The DTD in the document's own <!DOCTYPE> |
| none | Well-formedness only |
bin/console 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, used like a catalog slug: #[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 the schema changes, 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 rejects an invoice 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, given to the constraint as rules. It works with a catalog schema, your own schema or none:
final class InvoiceUpload
{
#[Assert\NotNull]
#[ValidXml(schema: 'peppol-bis-3-invoice', rules: 'config/rules/our-checks.sch')]
public ?UploadedFile $invoice = null;
}
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:
#[ValidXml(schema: 'peppol-bis-3-invoice', rules: [
'main.sch' => 'config/rules/main.sch',
'parts/totals.sch' => 'config/rules/parts/totals.sch',
])]
public ?UploadedFile $invoice = null;
Rules are compiled on the first request and kept for a week. Rules that don't compile give a "could not be validated" violation with the compiler's message; try them once with bin/console xmlvalid:validate sample.xml --rules=config/rules/our-checks.sch. On Pro, rules can be kept with a saved schema instead.
Each broken rule
$validation = $this->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 constraint: 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 Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Validator\Constraints as Assert;
use Xmlvalid\Symfony\Validator\ValidXml;
final class InvoiceUpload
{
#[Assert\NotNull]
#[ValidXml(schema: 'xrechnung-3-ubl')]
public ?UploadedFile $invoice = null;
}
The country guides name the right profile for each mandate: Germany, France, Belgium, Poland, Italy, Spain and more.
Whatever suppliers send
schema: '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; lastValidation()->schema['detected'] tells you which happened.
Factur-X and ZUGFeRD PDFs
A PDF goes through the same constraint: 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 pdf in the File constraint; the attachment's details are in $validation->result->pdf.
A batch
$validation = $this->xmlvalid->validate(Document::file($dir.'/march.zip'), schema: 'auto');
['passed' => $passed, 'total' => $total] = $validation->result->documents;
foreach ($validation->result->items as $item) {
$output->writeln(sprintf('%s %s: %d errors', $item->valid ? 'ok ' : 'BAD', $item->name, $item->errorsTotal));
}
A .zip is validated document by document, up to 1,000, each counted as one validation.
The client service
Autowire Xmlvalid\Client (also the public service xmlvalid), configured from the bundle's settings:
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Xmlvalid\Client;
use Xmlvalid\Document;
use Xmlvalid\Model\Validation;
final class InvoiceChecker
{
public function __construct(
private readonly Client $xmlvalid,
#[Autowire('%kernel.project_dir%')] private readonly string $projectDir,
) {}
public function check(string $path): Validation
{
return $this->xmlvalid->validate(Document::file($path), schema: 'peppol-bis-3-invoice');
}
}
$this->xmlvalid->validate(Document::string($xml), schemaFile: $this->projectDir.'/config/schemas/order.xsd');
$this->xmlvalid->validate(Document::url('https://example.com/feed.xml'), schema: 'my-12');
$this->xmlvalid->validate(Document::stream($filesystem->readStream($key), basename($key)), schema: 'auto'); // Flysystem
$this->xmlvalid->report($id); // the HTML report
$this->xmlvalid->share([$id]); // a public link for 30 days (Pro)
$this->xmlvalid->usage(); // this month's validations
$this->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 Messenger
Files and streams are sent as they are read, never held in memory. The constraint and validate() wait for the result (up to the bundle's timeout, 15 minutes by default), which suits a form with a typical invoice but not a large file in a web request.
Submit in the request, finish in a handler
// the controller: answer at once
$validation = $this->xmlvalid->submit(Document::file($upload->getPathname(), $upload->getClientOriginalName()), schema: 'my-12');
$import->setValidationId($validation->id);
$this->bus->dispatch(new CheckValidation($import->getId()), [new DelayStamp(15_000)]);
// the handler: look in, and come back while it runs
#[AsMessageHandler]
final class CheckValidationHandler
{
public function __construct(private Client $xmlvalid, private ImportRepository $imports, private MessageBusInterface $bus) {}
public function __invoke(CheckValidation $message): void
{
$import = $this->imports->find($message->importId);
$validation = $this->xmlvalid->validation($import->getValidationId());
if (! $validation->isFinished()) {
$import->setProgress($validation->progress);
$this->bus->dispatch($message, [new DelayStamp(15_000)]);
return;
}
$import->setValid($validation->isValid());
}
}
A handler can also call validate() and wait, as long as the worker's time limit allows it; on Pro, the ValidationCompleted event (webhooks) brings the result so nothing polls. Results are kept for 24 hours, and cancel($id) stops one you no longer need.
Webhooks and events
On Pro, xmlvalid calls your app when an API validation finishes or a URL monitor changes state. Import the route:
# config/routes/xmlvalid.yaml
xmlvalid:
resource: '@XmlvalidBundle/config/routes.php'
prefix: /xmlvalid
Add an endpoint under Dashboard → Webhooks with the URL https://your-app.com/xmlvalid/webhook and set webhook_secret to its signing secret. If a firewall covers /xmlvalid, let the route through without authentication: the signature is the check. The controller refuses a bad or stale signature (older than 5 minutes) and 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 |
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Xmlvalid\Symfony\Event\ValidationCompleted;
#[AsEventListener]
final class StoreValidationResult
{
public function __construct(private ImportRepository $imports) {}
public function __invoke(ValidationCompleted $completed): void
{
$import = $this->imports->findOneBy(['validationId' => $completed->validation->id]);
$import?->setValid($completed->validation->isValid());
}
}
Keep listeners quick, or dispatch a message for slow work. A delivery that fails is retried with the same $event->id; keep the ids you have handled if a listener must not run twice.
Console
bin/console xmlvalid:validate var/export/*.xml --schema=peppol-bis-3-invoice
bin/console xmlvalid:validate https://example.com/feed.xml --schema-file=config/feed.xsd --warnings
bin/console xmlvalid:validate invoice.xml --schema=my-12 --rules=config/rules/our-checks.sch
bin/console 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.
Testing
Point the bundle at a MockHttpClient in the test environment and answer with XmlvalidMockResponse:
# config/packages/test/xmlvalid.yaml
xmlvalid:
api_key: xv_test
http_client: xmlvalid.mock_client
services:
xmlvalid.mock_client:
class: Symfony\Component\HttpClient\MockHttpClient
public: true
use Xmlvalid\Symfony\Testing\XmlvalidMockResponse;
public function testItShowsTheBrokenRule(): void
{
$client = static::createClient();
static::getContainer()->get('xmlvalid.mock_client')->setResponseFactory([
XmlvalidMockResponse::invalid(['line' => 139, 'rule' => 'BR-CO-16', 'message' => 'Amount due for payment …']),
]);
$client->request('POST', '/invoices', files: ['invoice' => new UploadedFile(__DIR__.'/fixtures/invoice.xml', 'invoice.xml', test: true)]);
self::assertStringContainsString('[BR-CO-16] Amount due for payment', $client->getResponse()->getContent());
}
XmlvalidMockResponse::valid() answers with a passing validation, and XmlvalidMockResponse::validation($valid, $errors) returns the array for an answer of your own. Webhook events are plain events: dispatch them yourself to test a listener.
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. Decide whether uploads wait or pass unchecked, and log it so you notice.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 Messenger handler, let the retry strategy have it.
A 429 rate_limited is retried for you after the Retry-After the API gives, up to retries times.
Configuration and limits
# config/packages/xmlvalid.yaml
xmlvalid:
api_key: '%env(XMLVALID_API_KEY)%'
base_url: 'https://xmlvalid.com/api/v1'
timeout: 900 # seconds to wait for a result in all
wait: 10 # seconds the API holds a new request (0-60)
retries: 3 # retries of a 429 rate_limited
http_client: http_client
webhook_secret: '%env(default::XMLVALID_WEBHOOK_SECRET)%'
webhook_tolerance: 300
http_client names the HTTP client service the requests go through, for a scoped client with a proxy or a mock in tests.
| 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.