# Symfony bundle

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

> xmlvalid/xmlvalid-symfony: a validator constraint for uploaded XML and e-invoices, the client as a service, Messenger, webhook events and console commands, for Symfony 6.4, 7.4 and 8.

The bundle is built on the [PHP SDK](https://xmlvalid.com/docs/sdks/php): 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

```bash
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](https://xmlvalid.com/dashboard/api) and add it to `.env.local` (or a secret):

```ini
XMLVALID_API_KEY=xv_…
```

The key is read from `XMLVALID_API_KEY` by default; the other settings are [optional](#config).

## Validating uploads

`ValidXml` is a validator constraint, for a DTO, an entity or a form field:

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

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

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

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

```php
$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](#errors), not reported as violations: they are yours to fix, not the user's.

## Schemas

| Option | Validates against |
|---|---|
| `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 |
| `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](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`, 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:

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

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

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

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

`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

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

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

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

```yaml
# config/routes/xmlvalid.yaml
xmlvalid:
    resource: '@XmlvalidBundle/config/routes.php'
    prefix: /xmlvalid
```

Add an endpoint under [Dashboard → Webhooks](https://xmlvalid.com/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` |

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

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

```yaml
# 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
```

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

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