Why Factur-X is genuinely hard in PHP
Three technical realities make this format awkward in PHP specifically.
PDF/A-3b conformance. Factur-X is not just CII XML and not just a PDF. It is CII XML embedded inside a PDF/A-3b container with specific requirements around embedded fonts, ICC colour profiles, XMP metadata declaring the Factur-X version and profile, and the AFRelationship="Alternative" declaration on the XML attachment. PHP's mature PDF libraries (TCPDF, FPDF, mPDF, Dompdf) handle PDF generation but PDF/A-3b conformance requires careful manipulation that most implementations miss. Files that look correct in a PDF viewer fail when submitted to a Plateforme Agréée.
No XSLT 2.0 in PHP. EN 16931 Schematron validation requires XSLT 2.0 processing. PHP's built-in XSL extension is based on libxslt, which only supports XSLT 1.0. The official EN 16931 Schematron artefacts cannot run in PHP natively. The standard workaround is to shell out to a Java process running Saxon-HE, which defeats the purpose of staying in PHP and adds significant deployment complexity. This is why atgp/factur-x validates against XSD only, not against the business rules where most real validation failures actually occur.
Version maintenance. ZUGFeRD 2.4 (Factur-X 1.08) shipped with new mandatory fields and deprecated NIL elements. The September 2026 French mandate brings additional CIUS requirements through the Plateformes Agréées. Tracking these updates and integrating them into a PHP project is ongoing work that is unlikely to be the most valuable use of your development time.
Setting up the InvoiceXML client
The file-upload endpoints (validate, transform, extract, render) use Guzzle, the de facto standard PHP HTTP client. Install via Composer:
composer require guzzlehttp/guzzle
Set your API key as an environment variable. Sign up for a free InvoiceXML account and you will receive 100 credits to get started, no credit card required:
export INVOICEXML_API_KEY="your_key_here"
Create a reusable client class:
<?php
// src/Services/InvoiceXmlClient.php
namespace App\Services;
use GuzzleHttp\Client;
class InvoiceXmlClient
{
private Client $http;
public function __construct(?string $apiKey = null)
{
$apiKey = $apiKey ?? getenv('INVOICEXML_API_KEY');
if (!$apiKey) {
throw new \RuntimeException('INVOICEXML_API_KEY is not set');
}
$this->http = new Client([
'base_uri' => 'https://api.invoicexml.com',
'headers' => [
'Authorization' => "Bearer {$apiKey}",
],
'timeout' => 30,
]);
}
public function client(): Client
{
return $this->http;
}
}
In Laravel, register this as a singleton in AppServiceProvider. In Symfony, configure it as a service in services.yaml. For plain PHP, instantiate where needed.
Create Factur-X invoices in PHP
Send your invoice as JSON. Totals and the VAT breakdown are auto-calculated from the line items.
<?php
$apiKey = getenv('INVOICEXML_API_KEY');
$invoice = [
'invoice' => [
'invoiceNumber' => 'INV-2025-001',
'issueDate' => '2025-07-01',
'currency' => 'EUR',
'seller' => [
'name' => 'Acme GmbH',
'vatIdentifier' => 'DE123456789',
'legalRegistration' => ['identifier' => 'HRB98765'],
'postalAddress' => [
'line1' => 'Musterstr. 1',
'city' => 'Berlin',
'postCode' => '10115',
'country' => 'DE',
],
],
'buyer' => [
'name' => 'Example Corp',
'postalAddress' => [
'line1' => '12 Rue de Rivoli',
'city' => 'Paris',
'postCode' => '75001',
'country' => 'FR',
],
],
'paymentDetails' => [
'paymentAccountIdentifier' => 'DE89370400440532013000',
],
'lines' => [[
'quantity' => 10,
'item' => ['name' => 'Consulting Services'],
'priceDetails' => ['netPrice' => 150.00],
'vatInformation' => ['rate' => 19],
]],
],
];
$ch = curl_init('https://api.invoicexml.com/v1/create/facturx');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$apiKey}",
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($invoice),
CURLOPT_RETURNTRANSFER => true,
]);
file_put_contents('invoice-facturx.pdf', curl_exec($ch));
That is the whole request. The API computes totals and the VAT breakdown, embeds the factur-x.xml attachment, applies the PDF/A-3b conformance layer, and validates against EN 16931 Schematron before returning the binary PDF.
Full create example on GitHub →
Validate Factur-X invoices in PHP
Before sending an invoice to a customer or submitting it to a French Plateforme Agréée, validate that it passes all EN 16931 Schematron rules and PDF/A-3b conformance checks.
<?php
use App\Services\InvoiceXmlClient;
function validateFacturX(string $pdfPath): array
{
$client = (new InvoiceXmlClient())->client();
$response = $client->post('/v1/validate/facturx', [
'multipart' => [[
'name' => 'file',
'contents' => fopen($pdfPath, 'r'),
'filename' => basename($pdfPath),
]],
]);
return json_decode((string) $response->getBody(), true);
}
$result = validateFacturX('invoice-facturx.pdf');
if ($result['valid']) {
echo "Valid Factur-X invoice ({$result['data']['conformanceLevel']})\n";
} else {
echo "Validation failed with " . count($result['errors']) . " errors:\n";
foreach ($result['errors'] as $err) {
echo " [{$err['rule']}] {$err['message']}\n";
if (!empty($err['fields'])) {
echo " Fields: " . implode(', ', $err['fields']) . "\n";
}
}
}
Both valid and invalid invoices return HTTP 200. Branch on $result['valid'] rather than the HTTP status code. This keeps the integration clean without exception handling for the expected invalid case.
errors is a flat array of findings. Each entry carries a plain-language message, the business term codes (btCodes), the JSON paths into the document (fields), and the verbatim validator output (raw). warnings uses the same shape for non-blocking issues:
{
"valid": false,
"detail": "Validation failed with 1 error(s)",
"data": {
"schemaValid": true,
"schematronValid": false,
"conformanceLevel": "EN16931"
},
"errors": [
{
"rule": "BR-CO-14",
"line": null,
"message": "The invoice total VAT amount does not match the sum of VAT breakdown amounts.",
"btCodes": ["BT-110"],
"fields": ["totals.taxTotalAmount"],
"raw": "[BR-CO-14] EN16931: Invoice total VAT amount = sum of VAT category tax amounts. (at /...)"
}
],
"warnings": []
}
Full validation example on GitHub →
Convert PDF invoices to Factur-X
If you receive PDF invoices from suppliers and need to ingest them as Factur-X for archival or further processing, use the transform endpoint. The AI pipeline extracts every EN 16931 field from the source PDF, including scanned and photographed documents.
<?php
use App\Services\InvoiceXmlClient;
function pdfToFacturX(string $pdfPath): string
{
$client = (new InvoiceXmlClient())->client();
$response = $client->post('/v1/transform/to/facturx', [
'multipart' => [
['name' => 'file', 'contents' => fopen($pdfPath, 'r'), 'filename' => basename($pdfPath)],
['name' => 'language', 'contents' => 'en'],
],
]);
return (string) $response->getBody();
}
file_put_contents('supplier-facturx.pdf', pdfToFacturX('supplier-invoice.pdf'));
The optional language parameter (default en) localises the rendered PDF layer (labels, dates, currency formatting). The embedded CII XML is always emitted under the EN 16931 (Comfort) profile. The endpoint also accepts JPEG, PNG, TIFF, WEBP, HEIC, DOCX, and XLSX inputs in addition to PDF.
Full transform example on GitHub →
For incoming Factur-X invoices that need to be imported into your ERP or AP system, the extract endpoint returns either the raw CII XML or a fully parsed JSON representation:
<?php
use App\Services\InvoiceXmlClient;
function extractFacturX(string $pdfPath): array
{
$client = (new InvoiceXmlClient())->client();
$response = $client->post('/v1/extract/json', [
'multipart' => [[
'name' => 'file',
'contents' => fopen($pdfPath, 'r'),
'filename' => basename($pdfPath),
]],
]);
return json_decode((string) $response->getBody(), true);
}
$data = extractFacturX('incoming-invoice.pdf');
echo "Invoice {$data['invoiceNumber']} from {$data['seller']['name']}\n";
echo "Total: {$data['totals']['grandTotalAmount']} {$data['currency']}\n";
echo "Lines: " . count($data['lines']) . "\n";
To extract just the embedded CII XML rather than parsed JSON, use /v1/extract/xml instead. The response is the raw XML as application/xml.
Full extract example on GitHub →
Laravel integration: service class with dependency injection
For Laravel applications, wrap the InvoiceXML client in a service class and bind it as a singleton:
<?php
// app/Services/FacturXService.php
namespace App\Services;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Storage;
class FacturXService
{
public function __construct(private Client $http) {}
public function generate(array $invoice): string
{
$response = $this->http->post('/v1/create/facturx', [
'json' => ['invoice' => $invoice],
]);
return (string) $response->getBody();
}
public function generateAndStore(array $invoice, string $disk = 's3'): string
{
$pdf = $this->generate($invoice);
$path = "invoices/{$invoice['invoiceNumber']}.pdf";
Storage::disk($disk)->put($path, $pdf);
return $path;
}
}
Register in AppServiceProvider:
public function register(): void
{
$this->app->singleton(FacturXService::class, function ($app) {
return new FacturXService(new \GuzzleHttp\Client([
'base_uri' => 'https://api.invoicexml.com',
'headers' => [
'Authorization' => 'Bearer ' . config('services.invoicexml.key'),
],
'timeout' => 30,
]));
});
}
Inject it anywhere via constructor injection or the service container.
Symfony integration: service configuration
For Symfony applications, configure the service in config/services.yaml:
services:
GuzzleHttp\Client:
arguments:
- base_uri: 'https://api.invoicexml.com'
headers:
Authorization: 'Bearer %env(INVOICEXML_API_KEY)%'
timeout: 30
App\Service\FacturXService:
arguments:
$http: '@GuzzleHttp\Client'
Inject via constructor in any service or controller:
<?php
// src/Controller/InvoiceController.php
namespace App\Controller;
use App\Service\FacturXService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class InvoiceController extends AbstractController
{
public function __construct(private FacturXService $facturX) {}
#[Route('/invoices/{id}/facturx', methods: ['POST'])]
public function generateFacturX(int $id): Response
{
$invoice = $this->loadInvoice($id);
$pdf = $this->facturX->generate($invoice);
return new Response($pdf, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => "attachment; filename=\"{$invoice['number']}.pdf\"",
]);
}
}
Why use a REST API instead of building it in PHP
The most popular PHP Factur-X library is atgp/factur-x. It generates PDF files with embedded XML and provides XSD schema validation. For a quick proof of concept or basic use case, it works.
What it does not do, and what InvoiceXML handles:
EN 16931 Schematron validation across all 200+ business rules. PHP has no native XSLT 2.0 processor, so the Schematron rules cannot be executed in PHP without external dependencies. This is the layer where most real-world validation failures occur. An invoice can pass XSD validation perfectly while failing Schematron with cascading consequences when submitted to a Plateforme Agréée.
The full PDF/A-3b conformance pipeline including embedded fonts, ICC colour profile injection, XMP metadata with the correct Factur-X extension namespace, and the AFRelationship="Alternative" declaration that French Plateformes Agréées and German accounting systems like DATEV check on import. Most PHP implementations of PDF/A-3b are partial and the failures are invisible in standard PDF viewers but cause rejection at the network or accounting system layer.
National CIUS validation overlays. The same API handles Peppol BIS 3.0 for Belgian and Dutch B2B, XRechnung CIUS-REC-DE for German public sector, NLCIUS for Netherlands public sector, and EHF for Norway. If your PHP application serves customers in multiple European markets, switching profiles is a single parameter change rather than maintaining multiple PHP libraries.
Version maintenance. Updates for new ZUGFeRD versions, new Schematron releases, and new CIUS profile changes are deployed before the new specification's effective date with no integration change required on your side.
Stateless processing. Documents are processed in memory and purged immediately on response delivery. Nothing is written to disk, nothing is logged, no invoice data is used for model training. GDPR and HIPAA compliant by architecture.
Complete endpoint reference for PHP
| Operation | Endpoint | Input | Output |
| Create Factur-X | POST /v1/create/facturx | JSON invoice | PDF/A-3b binary |
| PDF to Factur-X (AI) | POST /v1/transform/to/facturx | PDF, image, DOCX, XLSX | PDF/A-3b binary |
| Validate Factur-X | POST /v1/validate/facturx | Factur-X PDF | Validation JSON |
| Extract as JSON | POST /v1/extract/json | Factur-X PDF | Structured JSON |
| Extract CII XML | POST /v1/extract/xml | Factur-X PDF | CII XML |
| Render as PDF | POST /v1/render/cii/to/pdf | CII XML | PDF binary |
Full OpenAPI 3.1 schema: api.invoicexml.com/v1/openapi. Interactive API explorer: api.invoicexml.com/v1/scalar.
Get started
Every PHP snippet in this guide is available as a runnable example on GitHub. Clone the repo, set INVOICEXML_API_KEY, and you have a working integration in under a minute:
InvoiceXML/facturx-api-examples/php →
Create a free InvoiceXML account: includes 100 credits to get started, no credit card required.
Related resources:
InvoiceXML is a REST API for European e-invoice compliance covering Factur-X, ZUGFeRD, XRechnung, Peppol UBL, and CII. Stateless processing, GDPR compliant by architecture, and integration support from any PHP application: Laravel, Symfony, WooCommerce, PrestaShop, or custom platforms.