Search by

logalysm / valide-facture

logalysm

Client PHP de l'API valide-facture.fr : validation de factures électroniques Factur-X (PDF), CII et UBL (XML), rapport et erreurs typés.

v1.0.2 2026-10-23 23:50 UTC

This package is auto-updated.

Last update: 2026-10-23 21:52:48 UTC


README

Client PHP de l'API de validation de valide-facture.fr : il envoie une facture électronique (PDF Factur-X, XML CII ou UBL) à POST /api/validate et rend un rapport typé, ou une exception typée en cas de refus.

Prérequis

  • PHP 8.3 ou plus récent ;
  • une clé API, créée dans votre espace sur valide-facture.fr ;
  • un client HTTP PSR-18 et des fabriques PSR-17 (par exemple symfony/http-client et nyholm/psr7). Si votre projet n'en a pas, le plugin Composer php-http/discovery en installe à l'installation du paquet.

Installation

composer config allow-plugins.php-http/discovery true
composer require logalysm/valide-facture

La première commande autorise le plugin php-http/discovery, dont dépend le paquet. Sans elle, Composer demande l'autorisation pendant l'installation (ou refuse le plugin en mode non interactif).

Valider un fichier

use Logalysm\ValideFacture\ValideFactureClient;

$client = new ValideFactureClient((string)getenv('VALIDE_FACTURE_API_KEY'));
$report = $client->validateFile('/chemin/vers/facture.pdf');

Gardez la clé hors du code source (variable d'environnement, gestionnaire de secrets). Le client ne l'écrit dans aucun message d'erreur ni aucun var_dump.

Lire le rapport

Une facture non conforme est une facture jugée : elle arrive dans le rapport, pas dans une exception.

use Logalysm\ValideFacture\Report\UnknownValue;
use Logalysm\ValideFacture\Report\Verdict;

$message = $report->verdict instanceof UnknownValue
    ? 'Verdict inconnu de cette version du client : ' . $report->verdict->value
    : match ($report->verdict) {
        Verdict::Compliant => 'Facture conforme.',
        Verdict::NonCompliant => sprintf('%d constat(s) à corriger.', count($report->findings)),
        Verdict::NotEvaluable => 'Facture non évaluée : le document n\'a pas pu être jugé.',
        Verdict::UnsupportedProfile => 'Profil de facture non pris en charge.',
    };

foreach ($report->findings as $finding) {
    echo $finding->code, ' : ', $finding->messageFr, PHP_EOL;
}

Chaque champ énuméré du rapport (verdict, classification, findings[].layer, findings[].severity, coverage, syntax) est typé Enum|UnknownValue. Une valeur ajoutée côté service après la sortie de votre version du client ne lève pas d'exception : elle arrive en UnknownValue, avec la valeur reçue dans ->value. Les champs que le client ne connaît pas encore sont ignorés.

$report->versions->rulesVersion porte la version des règles qui a produit le verdict.

Erreurs

Les erreurs d'appel au service implémentent Logalysm\ValideFacture\Exception\ValideFactureException.

ExceptionCause
\InvalidArgumentExceptionclé vide, fichier absent ou illisible (levée avant tout appel réseau)
MalformedRequestException, UnreadableDocumentExceptionrequête ou document refusé (400)
PayloadTooLargeExceptionfichier trop volumineux (413)
UnsupportedMediaTypeException, UnsupportedSyntaxExceptiontype de fichier ou syntaxe de facture non pris en charge (415)
UnauthenticatedExceptionclé absente, invalide ou révoquée (401)
InsufficientScopeExceptionla clé n'autorise pas la validation (403)
RateLimitExceededExceptiontrop de requêtes en rafale (429)
QuotaExceededExceptionquota de validations du mois atteint (429)
ValidationUnavailableExceptionvalidation temporairement indisponible (503)
ProblemExceptionrefus d'un type que cette version du client ne connaît pas ; classe parente des refus ci-dessus
UnexpectedResponseExceptionréponse hors contrat, par exemple une page HTML d'un frontal ou un rapport incomplet
TransportExceptionaucune réponse obtenue (hôte injoignable, délai dépassé)

Chaque refus (ProblemException et ses sous-classes) expose type, title, status, detail et correlationId. Pour toute question sur une requête, écrivez-nous depuis la page Contact en indiquant son correlationId : il permet de la retrouver.

Les deux 429 appellent deux gestes différents :

use Logalysm\ValideFacture\Exception\QuotaExceededException;
use Logalysm\ValideFacture\Exception\RateLimitExceededException;

try {
    $report = $client->validateFile($path);
} catch (RateLimitExceededException $exception) {
    // Attendre puis réessayer : le service indique le délai en secondes (null s'il ne l'a pas indiqué).
    sleep($exception->retryAfterInSeconds ?? 5);
    $report = $client->validateFile($path);
} catch (QuotaExceededException $exception) {
    // Ne pas réessayer : le quota du mois est épuisé, $exception->detail dit jusqu'à quand.
    throw $exception;
}

Délai

PSR-18 ne porte pas de délai : il se règle sur le client HTTP. Une durée totale de 35 secondes est recommandée. Avec symfony/http-client :

use Logalysm\ValideFacture\ValideFactureClient;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Psr18Client;

$client = new ValideFactureClient(
    (string)getenv('VALIDE_FACTURE_API_KEY'),
    httpClient: new Psr18Client(HttpClient::create([
        'max_duration' => 35,
    ])),
);

Intégration continue

Le paquet installe la commande vendor/bin/valide-facture. Dans votre CI, elle valide un dossier de factures produites par votre code et rend un code de sortie :

vendor/bin/valide-facture check tests/factures --junit rapport-junit.xml

La clé se lit dans la variable d'environnement VALIDE_FACTURE_API_KEY, à déclarer comme secret de votre CI. Aucune option ne la reçoit : passée en argument, elle finirait dans l'historique du shell et dans les journaux du job.

Un dossier est parcouru récursivement et seuls ses fichiers .pdf et .xml sont envoyés ; un fichier nommé en argument est envoyé quelle que soit son extension. Les fichiers sont validés un à la fois, triés par chemin : comptez quelques secondes par fichier.

Chaque fichier validé consomme une unité de quota, à chaque exécution du pipeline. Un refus d'entrée (fichier vide, trop volumineux, ni PDF ni XML) et une indisponibilité du service n'en consomment pas.

Un code 0 dit que vos factures passent les contrôles du service : format du fichier, règles de la norme EN 16931 et contrôles de la réforme française. C'est une aide au diagnostic, qui ne se substitue pas à la décision de votre Plateforme Agréée.

Codes de sortie

CodeSignification
0tous les fichiers sont conformes
1au moins un fichier est en échec : c'est votre facture qu'il faut corriger
2avec --baseline : régression par rapport au fichier de référence (voir « Détection de régression »)
3le test n'a pas pu se faire : il ne dit rien de vos factures

Le code le plus élevé l'emporte. Détail par fichier :

Réponse du serviceCode
rapport CONFORME0
tout autre verdict (NON_CONFORME, NON_EVALUABLE, verdict inconnu de votre version)1
document inanalysable (400 unreadable-document), trop volumineux (413), type ou syntaxe non pris en charge (415)1
requête mal formée (400 malformed-request), clé absente, invalide ou révoquée (401), clé sans droit de validation (403), quota atteint (429 quota-exceeded), service indisponible (503)3
refus d'un type inconnu de votre version, réponse hors contrat, erreur réseau, délai dépassé3

La commande sort aussi en 3, avant tout appel, si la clé est absente, si un chemin est introuvable, si aucun fichier .pdf ou .xml n'est trouvé, ou si une option est inconnue.

Sur un refus pour débit trop élevé (429 rate-limit-exceeded), la commande attend le délai indiqué par le service, puis réessaie, trois fois au plus ; au-delà, le fichier sort en 3. Sur un refus qui vise la clé ou le compte (401, 403, quota atteint), elle s'arrête : les fichiers suivants sont rapportés non exécutés. Chaque appel est borné à 35 secondes quand symfony/http-client ou guzzlehttp/guzzle est installé ; sinon la commande le signale sur la sortie d'erreur.

Rapports

La sortie standard donne une ligne par fichier, avec le verdict ou le statut HTTP et le type de problème servis, puis les constats des fichiers en échec. La progression s'écrit sur la sortie d'erreur.

--junit <chemin> écrit un rapport JUnit XML : un cas de test par fichier, <failure> pour un code 1 (code de règle, localisation et message de chaque constat) ou 2, <error> pour un code 3, <skipped> pour un fichier non exécuté. GitLab l'affiche nativement (artifacts:reports:junit) ; sur GitHub, il s'affiche par une action de rapport JUnit.

--format=json remplace la sortie standard par un document JSON, et rien d'autre :

{
    "schemaVersion": 1,
    "exitCode": 1,
    "files": [
        {
            "path": "tests/factures/avoir.xml",
            "exitCode": 1,
            "httpStatus": 200,
            "problemType": null,
            "verdict": "NON_CONFORME",
            "findings": [
                {
                    "code": "SCH/BR-03",
                    "severity": "ERROR",
                    "location": "/rsm:CrossIndustryInvoice",
                    "messageFr": "La date d'émission de la facture est absente."
                }
            ],
            "versions": {
                "specVersion": "…",
                "sidecarTag": "…",
                "rulesVersion": "…",
                "validatorVersion": "…"
            },
            "message": null
        }
    ]
}

exitCode d'un fichier vaut 0, 1, 2 (avec --baseline), 3, ou null s'il n'a pas été exécuté. verdict, problemType et severity sont les valeurs servies par l'API. httpStatus est null sans réponse ou pour un rapport hors contrat ; versions est null sans rapport ; message explique un refus ou une erreur technique. Un ajout de champ reste en schemaVersion 1 ; un changement incompatible incrémente schemaVersion.

VALIDE_FACTURE_BASE_URI change l'adresse du service (par défaut https://valide-facture.fr).

Détection de régression

Un dossier de jeux d'essai contient souvent, au départ, des factures déjà non conformes que vous ne corrigerez pas tout de suite. Avec check seul, le pipeline resterait rouge en permanence. Le fichier de référence règle ce cas : il photographie les résultats d'une exécution, se versionne dans votre dépôt, et chaque exécution suivante n'échoue que sur ce qui a changé en pire.

# Une première fois, puis après chaque changement de résultat que vous acceptez :
vendor/bin/valide-facture baseline update tests/factures
git add valide-facture.baseline.json

# Dans le pipeline :
vendor/bin/valide-facture check --baseline valide-facture.baseline.json tests/factures --junit rapport-junit.xml

baseline update valide les fichiers comme check et écrit valide-facture.baseline.json dans le répertoire courant (--baseline <chemin> pour un autre emplacement). Il consomme une unité de quota par fichier validé, comme check. Si un fichier finit en erreur technique ou n'est pas exécuté, rien n'est écrit : la référence existante reste intacte et la commande sort en 3. Régénérée sur les mêmes résultats, la référence est identique octet pour octet.

Pour chaque fichier, indexé par son chemin relatif au répertoire de la référence, le fichier de référence contient l'empreinte SHA-256 de ses octets, l'issue servie (httpStatus, problemType, verdict), ses codes d'erreur (les constats dont la sévérité n'est ni WARNING, ni NOTICE, ni INFO) et trois versions du rapport. Il ne contient aucun message, aucune localisation, aucun contenu de facture : un message ou une localisation peut citer une valeur de la facture.

{
    "schemaVersion": 1,
    "files": {
        "tests/factures/avoir.xml": {
            "sha256": "…",
            "httpStatus": 200,
            "problemType": null,
            "verdict": "NON_CONFORME",
            "errorCodes": [
                "SCH/BR-03"
            ],
            "versions": {
                "specVersion": "…",
                "sidecarTag": "…",
                "rulesVersion": "…"
            }
        }
    }
}

Avec --baseline, chaque fichier est comparé à son entrée :

RésultatExempleCode du fichierAvec --strict
inchangémême issue, mêmes codes d'erreur0, y compris pour un fichier non conforme connu de la référence0
régressionconforme devenu non conforme, autre issue, code d'erreur nouveau22
améliorédevenu conforme, codes d'erreur en moins0, signalé (« lancez baseline update »)2
nouveauabsent de la référencecelui de check (0 ou 1), signalé2
manquantprésent dans la référence, absent des fichiers testés2, fichier nommé2
erreur techniqueservice indisponible, réseau, clé refusée3, sans comparaison3

--strict fait échouer toute différence de résultat avec la référence, dans les deux sens ; il exige --baseline. Une empreinte ou une version qui change à résultat égal n'est pas une différence : un PDF régénéré avec un autre horodatage ne fait pas échouer le pipeline, même avec --strict. Les codes d'erreur se comparent comme un ensemble : une seconde erreur d'un code déjà présent n'est pas vue.

La référence est lue avant le premier appel. Absente, au JSON invalide, d'une schemaVersion inconnue ou avec une entrée malformée, elle est refusée en 3 par un message qui en nomme la cause, sans consommer de quota. Un fichier testé hors du répertoire de la référence est refusé de même.

Cause probable

Devant une régression, la commande dit si elle vient plutôt de votre code ou du validateur. Elle compare à la référence l'empreinte du fichier et les versions du rapport (rulesVersion, sidecarTag, specVersion) :

EmpreinteVersionsCause dite
différenteidentiques« Cause probable : votre code a changé. »
identiqueidentiques« Cause : le validateur a changé (mêmes octets, autre résultat). »
identiqueau moins une a changé« Cause : le validateur a changé ; les règles sont passées de X à Y. »
différenteau moins une a changé« Cause probable : votre code a changé, ou les règles sont passées de X à Y. »

La cause n'est que probable quand l'empreinte a changé : rulesVersion change avec tout verdict modifié sur le corpus de test du service, mais un changement qu'aucun fichier de ce corpus ne révèle peut survenir sans qu'elle bouge. Quand un des deux côtés n'a pas de rapport (un refus 415, par exemple), les versions ne sont pas comparables et la cause repose sur la seule empreinte.

Pour que la cause se lise, produisez des jeux d'essai déterministes (voir « Jeux d'essai »).

Sorties

La sortie texte marque chaque fichier comparé ([régression], [amélioré], [nouveau], [manquant], [non conforme, connu de la référence]), écrit la cause ou le signalement sous la ligne du fichier, et le résumé compte les fichiers manquants et les régressions.

Avec --format=json, chaque fichier porte deux champs de plus, baselineKey (sa clé dans la référence) et baseline :

"baselineKey": "tests/factures/avoir.xml",
"baseline": {
    "status": "regression",
    "fileChanged": false,
    "versionChanges": [
        {
            "name": "rulesVersion",
            "from": "…",
            "to": "…"
        }
    ],
    "message": "Régression : CONFORME dans la référence, NON_CONFORME [SCH/BR-03] aujourd'hui. Cause : le validateur a changé ; les règles sont passées de … à …."
}

status vaut unchanged, regression, improved, new ou missing. fileChanged et versionChanges valent null pour un fichier nouveau ou manquant, versionChanges aussi quand un côté n'a pas de rapport. baseline vaut null pour un fichier en erreur technique ou non exécuté. Un fichier manquant est une entrée de files dont path est sa clé. Sans --baseline, ces deux champs sont absents.

Dans le rapport JUnit, une régression et un fichier manquant sont des <failure type="regression"> dont le message dit l'issue et la cause ; sous --strict, une autre différence est un <failure type="strict">. Un signalement qui ne fait pas échouer le fichier (amélioré, nouveau) est écrit dans <system-out>.

Jeux d'essai

Produisez des jeux d'essai déterministes : horloge figée, numéros de facture fixes, aucun identifiant aléatoire. Sinon l'empreinte de chaque fichier change à chaque génération, et une régression due à de nouvelles règles se lit « votre code a changé, ou les règles sont passées de X à Y ».

Dix factures de test, poussées vingt fois dans le mois, consomment deux cents unités de quota.

GitLab CI

Le job valide tests/factures et compare le résultat au fichier de référence valide-facture.baseline.json, versionné à la racine du dépôt :

valide-facture:
    stage: test
    image: composer:2 # ou l'image PHP de votre projet, avec Composer
    script:
        - composer install --no-interaction --no-progress
        # Les factures de tests/factures sont produites par votre code, ou versionnées.
        - vendor/bin/valide-facture check --baseline valide-facture.baseline.json tests/factures --junit rapport-junit.xml
    artifacts:
        when: always
        reports:
            junit: rapport-junit.xml

Déclarez la clé en variable CI/CD du projet (Settings > CI/CD > Variables), nommée VALIDE_FACTURE_API_KEY, en visibilité Masked (ou Masked and hidden, qui ne se choisit qu'à la création). Laissez Protect variable décoché, sauf si le job ne tourne que sur des branches protégées : une variable protégée n'est transmise qu'à leurs pipelines, et ailleurs la commande sort en 3, clé absente.

when: always envoie le rapport même quand le job échoue ; par défaut, GitLab ne l'envoie qu'en cas de succès. Le rapport s'affiche dans l'onglet Tests du pipeline et dans la demande de fusion.

GitHub Actions

Le même contrôle, dans .github/workflows/valide-facture.yml :

name: valide-facture

on:
    push:
        branches: [main]
    pull_request:

permissions:
    contents: read

jobs:
    valide-facture:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
            - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
              with:
                  php-version: '8.3' # la version de PHP de votre projet
            - run: composer install --no-interaction --no-progress
            # Les factures de tests/factures sont produites par votre code, ou versionnées.
            - run: vendor/bin/valide-facture check --baseline valide-facture.baseline.json tests/factures --junit rapport-junit.xml
              env:
                  VALIDE_FACTURE_API_KEY: ${{ secrets.VALIDE_FACTURE_API_KEY }}
            - uses: mikepenz/action-junit-report@6ef3fdcfcd5f0b5d14df3e659ef19b8c6820f93d # v6.6.1
              if: ${{ !cancelled() }}
              with:
                  report_paths: rapport-junit.xml
                  annotate_only: true

Déclarez la clé en secret du dépôt (Settings > Secrets and variables > Actions), nommé VALIDE_FACTURE_API_KEY. Seule l'étape qui lance la commande la reçoit. Sur une pull request ouverte depuis un fork, GitHub ne transmet pas les secrets : la commande sort en 3, clé absente.

Le workflow tourne sur les push de main et sur les pull requests : une branche qui a une pull request ouverte n'est pas validée deux fois par push.

GitHub n'affiche pas de rapport JUnit de lui-même. La dernière étape le lit et l'écrit dans le résumé du job, même quand la commande a échoué : mikepenz/action-junit-report est une action tierce, sous licence Apache-2.0, que vous pouvez remplacer par tout lecteur de JUnit. Avec annotate_only: true, elle ne crée pas de check run et le droit contents: read lui suffit. Chaque action est épinglée par l'empreinte de son commit : une étiquette peut être déplacée, une empreinte non.

Symfony

Dans votre application, installez le client avec le client HTTP de Symfony et une implémentation PSR-7 :

composer config allow-plugins.php-http/discovery true
composer require logalysm/valide-facture symfony/http-client nyholm/psr7

Déclarez le client comme service. Il passe par un client HTTP dédié, borné à 35 secondes sur la durée totale de l'appel (max_duration : timeout est un délai d'inactivité, qui couperait une validation longue) :

framework:
    http_client:
        scoped_clients:
            valide_facture.client:
                base_uri: '%env(VALIDE_FACTURE_BASE_URI)%'
                max_duration: 35

services:
    Logalysm\ValideFacture\ValideFactureClient:
        arguments:
            $apiKey: '%env(VALIDE_FACTURE_API_KEY)%'
            $baseUri: '%env(VALIDE_FACTURE_BASE_URI)%'
            $httpClient: '@psr18.valide_facture.client'

Symfony fournit le service psr18.valide_facture.client, version PSR-18 du client HTTP déclaré au-dessus. Ajoutez l'adresse du service à la fin de votre fichier .env :

VALIDE_FACTURE_BASE_URI=https://valide-facture.fr

Ne rangez jamais la clé dans un fichier versionné. En développement, collez la clé complète dans votre fichier .env.local, que Symfony ne versionne pas :

VALIDE_FACTURE_API_KEY=votre-clé

En production, rangez-la avec php bin/console secrets:set VALIDE_FACTURE_API_KEY --env=prod, qui vous demande la valeur, ou déclarez VALIDE_FACTURE_API_KEY en variable d'environnement du serveur. Ne versionnez jamais le fichier config/secrets/prod/prod.decrypt.private.php.

Injectez le client là où vous en avez besoin, par exemple dans une commande :

<?php

namespace App\Command;

use Logalysm\ValideFacture\Exception\ProblemException;
use Logalysm\ValideFacture\Exception\QuotaExceededException;
use Logalysm\ValideFacture\Exception\RateLimitExceededException;
use Logalysm\ValideFacture\Exception\TransportException;
use Logalysm\ValideFacture\Exception\UnexpectedResponseException;
use Logalysm\ValideFacture\Report\Verdict;
use Logalysm\ValideFacture\ValideFactureClient;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand('app:validate-invoice', 'Valide une facture avec valide-facture.fr')]
final class ValidateInvoiceCommand
{
    public function __construct(
        private readonly ValideFactureClient $client,
    ) {
    }

    public function __invoke(SymfonyStyle $console, #[Argument('Chemin du fichier à valider')] string $invoicePath): int
    {
        try {
            $report = $this->client->validateFile($invoicePath);
        } catch (RateLimitExceededException $exception) {
            $console->error(sprintf('Trop de requêtes : réessayez dans %d seconde(s).', $exception->retryAfterInSeconds ?? 5));

            return Command::FAILURE;
        } catch (QuotaExceededException $exception) {
            $console->error('Quota de validations du mois atteint : ' . $exception->detail);

            return Command::FAILURE;
        } catch (ProblemException $exception) {
            $console->error($exception->getMessage());
            $console->text([
                'Type : ' . $exception->type,
                'Référence à transmettre au support : ' . ($exception->correlationId ?? 'aucune'),
            ]);

            return Command::FAILURE;
        } catch (TransportException|UnexpectedResponseException $exception) {
            $console->error($exception->getMessage());

            return Command::FAILURE;
        }

        $console->section('Verdict : ' . $report->verdict->value);
        $console->text(match ($report->verdict) {
            Verdict::Compliant => 'La facture est conforme.',
            Verdict::NonCompliant => 'La facture n\'est pas conforme : corrigez les constats ci-dessous.',
            Verdict::NotEvaluable => 'La facture n\'a pas pu être entièrement évaluée.',
            Verdict::UnsupportedProfile => 'Le profil déclaré par la facture n\'est pas pris en charge.',
            default => 'Verdict inconnu de cette version du client.',
        });

        foreach ($report->findings as $finding) {
            $console->writeln(sprintf('- [%s] %s : %s', $finding->severity->value, $finding->code, $finding->messageFr));
        }

        return $report->verdict === Verdict::Compliant ? Command::SUCCESS : Command::FAILURE;
    }
}

Placez une facture à la racine du projet sous le nom facture.pdf, puis lancez :

php bin/console app:validate-invoice facture.pdf

La commande ne rend Command::SUCCESS que pour une facture conforme. Les autres exceptions du client sont listées dans « Erreurs ».

Sans le client, avec le seul symfony/http-client : voir le guide Valider vos factures depuis Symfony.

Licence

MIT, voir LICENSE.