Guides
Valider vos factures depuis Symfony
Votre application Symfony émet des factures ? Contrôlez chacune avant de l'envoyer, en appelant l'API de validation avec le client HTTP de Symfony. Voici le code complet, à copier tel quel : configuration, clé API, envoi du fichier, lecture du rapport et traitement des erreurs.
Avant de commencer
-
Un compte, et une clé API créée sur votre page
de clés API. Copiez la clé complète dès sa création : elle ne s'affiche
qu'une fois. Elle a la forme
fxk_<préfixe>.<secret>. - Une application Symfony, dans laquelle vous installerez le client HTTP.
- Une facture à contrôler : un PDF Factur-X, ou un XML CII ou UBL. Pour voir le rapport sans écrire de code, déposez d'abord votre fichier dans le validateur.
Installer le client HTTP
À la racine de votre projet, installez le client HTTP de Symfony et le composant Mime, qui construit l'envoi du fichier :
composer require symfony/http-client symfony/mime
Configurer un client dédié et ranger la clé
Créez un client HTTP réservé à valide-facture.fr. Il porte l'adresse du service, votre clé
dans l'en-tête Authorization, et un délai d'attente de 35 secondes sur la
durée totale de l'appel, la valeur que la documentation de l'API recommande :
framework:
http_client:
scoped_clients:
valide_facture.client:
base_uri: '%env(VALIDE_FACTURE_BASE_URI)%'
auth_bearer: '%env(VALIDE_FACTURE_API_KEY)%'
max_duration: 35
Ajoutez l'adresse du service à la fin de votre fichier .env :
VALIDE_FACTURE_BASE_URI=https://valide-facture.fr
Rangez la clé comme un secret, jamais dans un fichier versionné. La commande vous demande la valeur : collez la clé complète.
php bin/console secrets:set VALIDE_FACTURE_API_KEY
En production, rangez-la de la même façon avec
php bin/console secrets:set VALIDE_FACTURE_API_KEY --env=prod, ou déclarez
VALIDE_FACTURE_API_KEY en variable d'environnement du serveur. Ne versionnez
jamais le fichier config/.
Écrire le service qui appelle l'API
Le service envoie le fichier dans le champ file et rend le rapport. Quand le
service n'a pas pu juger la facture, il lève une exception qui dit quoi faire. Commencez
par l'exception commune :
<?php
namespace App\ValideFacture;
class ValideFactureException extends \RuntimeException
{
public function __construct(
string $message,
public readonly int $status,
public readonly ?string $type = null,
public readonly ?string $correlationId = null,
) {
parent::__construct($message);
}
}
Puis une exception par geste à faire : la clé refusée, le quota atteint, le document refusé, le service indisponible :
<?php
namespace App\ValideFacture;
final class ApiKeyRejectedException extends ValideFactureException
{
}
<?php
namespace App\ValideFacture;
final class QuotaExceededException extends ValideFactureException
{
}
<?php
namespace App\ValideFacture;
final class DocumentRejectedException extends ValideFactureException
{
}
<?php
namespace App\ValideFacture;
final class ServiceUnavailableException extends ValideFactureException
{
}
L'exception de débit porte en plus le délai d'attente que le service indique :
<?php
namespace App\ValideFacture;
final class RateLimitedException extends ValideFactureException
{
public function __construct(
string $message,
int $status,
?string $type,
?string $correlationId,
public readonly int $retryAfter,
) {
parent::__construct($message, $status, $type, $correlationId);
}
}
Enfin, le service lui-même :
<?php
namespace App\ValideFacture;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
final class InvoiceValidator
{
public function __construct(
#[Target('valide_facture.client')]
private readonly HttpClientInterface $client,
) {
}
/**
* Envoie la facture et rend le rapport de validation.
*
* @return array<string, mixed>
*
* @throws ValideFactureException si le service n'a pas pu juger la facture
*/
public function validate(string $path): array
{
$form = new FormDataPart(['file' => DataPart::fromPath($path)]);
$response = $this->client->request('POST', '/api/validate', [
'headers' => $form->getPreparedHeaders()->toArray(),
'body' => $form->bodyToIterable(),
]);
// Sans réponse (hôte injoignable, délai de 35 secondes dépassé), le client lève une erreur de transport.
try {
return $this->read($response);
} catch (TransportExceptionInterface $e) {
throw new ServiceUnavailableException('Le service n\'a pas répondu : ' . $e->getMessage(), 0);
}
}
/**
* @return array<string, mixed>
*/
private function read(ResponseInterface $response): array
{
$status = $response->getStatusCode();
// Une facture non conforme est une facture jugée : elle arrive ici, en 200.
if (200 === $status) {
return $response->toArray();
}
$headers = $response->getHeaders(false);
// Certains refus (fichier trop gros, trop de requêtes) peuvent arriver en HTML.
if (!str_starts_with($headers['content-type'][0] ?? '', 'application/problem+json')) {
throw new ValideFactureException(sprintf('Réponse inattendue du service (HTTP %d).', $status), $status);
}
$problem = $response->toArray(false);
$type = $problem['type'] ?? '';
$detail = $problem['detail'] ?? '';
$correlationId = $problem['correlationId'] ?? null;
throw match ($type) {
'https://valide-facture.fr/problems/unauthenticated',
'https://valide-facture.fr/problems/insufficient-scope' => new ApiKeyRejectedException($detail, $status, $type, $correlationId),
'https://valide-facture.fr/problems/rate-limit-exceeded' => new RateLimitedException($detail, $status, $type, $correlationId, (int) ($headers['retry-after'][0] ?? 1)),
'https://valide-facture.fr/problems/quota-exceeded' => new QuotaExceededException($detail, $status, $type, $correlationId),
'https://valide-facture.fr/problems/malformed-request',
'https://valide-facture.fr/problems/unreadable-document',
'https://valide-facture.fr/problems/payload-too-large',
'https://valide-facture.fr/problems/unsupported-media-type',
'https://valide-facture.fr/problems/unsupported-syntax' => new DocumentRejectedException($detail, $status, $type, $correlationId),
'https://valide-facture.fr/problems/validation-unavailable' => new ServiceUnavailableException($detail, $status, $type, $correlationId),
default => new ValideFactureException($detail, $status, $type, $correlationId),
};
}
}
Le nom valide_facture.client de l'attribut #[Target] est celui
du client déclaré dans la configuration : si vous renommez l'un, renommez l'autre.
Essayer depuis la console
Ajoutez une commande qui affiche le verdict et chaque anomalie :
<?php
namespace App\Command;
use App\ValideFacture\ApiKeyRejectedException;
use App\ValideFacture\DocumentRejectedException;
use App\ValideFacture\InvoiceValidator;
use App\ValideFacture\QuotaExceededException;
use App\ValideFacture\RateLimitedException;
use App\ValideFacture\ServiceUnavailableException;
use App\ValideFacture\ValideFactureException;
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:valider-facture', 'Valide une facture avec valide-facture.fr')]
final class ValiderFactureCommand
{
public function __construct(
private readonly InvoiceValidator $validator,
) {
}
public function __invoke(SymfonyStyle $io, #[Argument('Chemin du fichier à valider')] string $fichier): int
{
try {
$rapport = $this->validator->validate($fichier);
} catch (ValideFactureException $e) {
$io->error(match (true) {
$e instanceof ApiKeyRejectedException => 'Clé API refusée : vérifiez VALIDE_FACTURE_API_KEY et qu\'elle autorise la validation.',
$e instanceof RateLimitedException => sprintf('Trop de requêtes : réessayez dans %d seconde(s).', $e->retryAfter),
$e instanceof QuotaExceededException => 'Quota de validations atteint.',
$e instanceof DocumentRejectedException => 'Le fichier n\'a pas pu être jugé.',
$e instanceof ServiceUnavailableException => 'Service momentanément indisponible : réessayez plus tard.',
default => sprintf('Erreur inattendue (HTTP %d).', $e->status),
});
$io->text([$e->getMessage(), sprintf('Type : %s', $e->type ?? 'aucun')]);
if (null !== $e->correlationId) {
$io->text(sprintf('Référence à transmettre au support : %s', $e->correlationId));
}
return Command::FAILURE;
}
$io->section(sprintf('Verdict : %s', $rapport['verdict']));
$io->text(match ($rapport['verdict']) {
'CONFORME' => 'La facture est conforme.',
'NON_CONFORME' => 'La facture n\'est pas conforme : corrigez les anomalies ci-dessous.',
// Plus produit depuis la Story 5.42 : un profil non admis au flux 2 rend NON_CONFORME.
'PROFIL_NON_SUPPORTE' => 'Le profil déclaré par la facture n\'est pas pris en charge.',
'NON_EVALUABLE' => 'La facture n\'a pas pu être entièrement évaluée.',
default => 'Verdict non reconnu par cette version de l\'intégration.',
});
foreach ($rapport['findings'] as $constat) {
$io->writeln(sprintf('- [%s] %s : %s', $constat['severity'], $constat['code'], $constat['messageFr']));
if (null !== ($constat['correctionHint'] ?? null)) {
$io->writeln(sprintf(' Correction : %s', $constat['correctionHint']));
}
}
return 'CONFORME' === $rapport['verdict'] ? Command::SUCCESS : Command::FAILURE;
}
}
Placez une facture à la racine du projet sous le nom facture.pdf, puis lancez :
php bin/console app:valider-facture facture.pdf
Lire le rapport
Le membre verdict prend l'une de trois valeurs :
CONFORME, NON_CONFORME et NON_EVALUABLE. Le contrat
garde une quatrième valeur, PROFIL_NON_SUPPORTE, qui n'est plus produite.
Un NON_CONFORME n'est pas une erreur : la facture a été
jugée, et la réponse arrive en 200 avec le rapport complet. Traitez-la dans
votre logique métier, pas dans votre gestion d'erreurs.
Chaque anomalie de findings porte sa gravité (severity), le code
de la règle (code), l'explication en français (messageFr),
l'endroit du fichier (location) et une piste de correction
(correctionHint). Chaque code a sa page parmi
les codes d'erreur du validateur.
De nouvelles valeurs peuvent apparaître dans le rapport au fil des versions. Prévoyez toujours une branche par défaut, comme la commande ci-dessus, plutôt qu'une liste fermée qui lève une exception.
Traiter les erreurs
Quand le service n'a pas pu juger la facture, il répond en
application/problem+json. Branchez votre traitement sur le membre
type, jamais sur le seul code HTTP : deux refus de sens opposés partagent
le 429.
-
unauthenticated(401) : vérifiez la clé. Elle est absente, révoquée ou expirée ; créez-en une nouvelle si besoin. -
insufficient-scope(403) : créez une clé qui autorise la validation, puis remplacez celle de votre intégration. -
rate-limit-exceeded(429) : attendez le nombre de secondes indiqué par l'en-têteRetry-After, puis renvoyez la facture. -
quota-exceeded(429, sansRetry-After) : attendre quelques secondes n'y change rien. Changez d'abonnement, ou attendez la période suivante. -
malformed-request,unreadable-document,payload-too-large,unsupported-media-typeetunsupported-syntax: corrigez la requête ou le fichier. Renvoyer le même envoi donnera le même refus. -
validation-unavailable(503) : renvoyez la facture plus tard, et gardez l'identifiantcorrelationIdsi vous contactez le support. -
Aucune réponse dans les 35 secondes :
InvoiceValidatorlève aussiServiceUnavailableException. Renvoyez la facture plus tard.
Chaque type est une adresse, sous
https://valide-facture.fr/problems/ : ouvrez-la pour lire sa
documentation. Lisez aussi l'en-tête Content-Type avant de décoder : un
fichier très volumineux ou un afflux de requêtes peut être refusé en amont, par une page
HTML.
Aller plus loin
Chaque réponse, chaque type d'erreur et le schéma du rapport sont décrits dans
la documentation de l'API. Le nombre de validations
compris dans votre abonnement figure sur la page des
tarifs.
Déposez une facture dans le validateur pour
voir un rapport complet sans écrire de code.
Portée de cette page
Ce code est un exemple à intégrer dans votre application, pas une bibliothèque : aucun paquet Composer n'est à installer en dehors des composants de Symfony. Il envoie une facture et lit la réponse ; il ne relance rien de lui-même. Décidez dans votre application quand renvoyer une facture refusée.
Guides