Why Peppol UBL is genuinely hard in PHP
Three technical realities make UBL particularly awkward in PHP specifically.
No XSLT 2.0 in PHP, but Peppol needs it twice. Peppol BIS 3.0 validation requires both the EN 16931 base Schematron (200+ rules) and the Peppol BIS 3.0 overlay (50+ additional rules). Both are XSLT 2.0 artefacts. PHP's built-in XSL extension is based on libxslt, which only supports XSLT 1.0. Running Peppol Schematron in pure PHP is not possible. The standard workaround is shelling out to a Java process running Saxon-HE, which adds significant deployment complexity and undermines the point of building in PHP. This is why existing PHP UBL libraries skip Schematron entirely and rely on XSD-only validation, which catches structural errors but not the business rule violations where most real-world rejections occur.
Quarterly Peppol BIS updates. OpenPeppol releases new Peppol BIS Schematron artefacts roughly every quarter. Each release requires downloading the new XSLT files, regression-testing against your invoice samples (because rule semantics occasionally change), and deploying. Miss a quarterly release and your validation accepts invoices that real Peppol access points reject. The maintenance cost compounds.
National CIUS profile fragmentation. A single UBL API needs to handle Peppol BIS 3.0 for Belgium and cross-border, NLCIUS for Dutch public sector, EHF for Norway, PINT variants for Singapore, Australia, Japan, Malaysia, and New Zealand, and XRechnung UBL for German B2G. Each has its own Schematron overlay and slightly different mandatory fields. Implementing this routing in PHP is non-trivial and rarely worth the engineering investment for a non-core feature.
Setting up the InvoiceXML client
The file-upload endpoints (validate, transform, extract, render) use Guzzle, the de facto standard PHP HTTP client:
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 Peppol UBL invoices in PHP
Send your invoice as JSON. Totals and the VAT breakdown are auto-calculated from the line items. Set the Peppol profile via options.profile.
<?php
$apiKey = getenv('INVOICEXML_API_KEY');
$invoice = [
'invoice' => [
'invoiceNumber' => 'INV-2026-001',
'issueDate' => '2026-02-15',
'currency' => 'EUR',
'seller' => [
'name' => 'Belga Solutions BVBA',
'vatIdentifier' => 'BE0123456789',
'postalAddress' => [
'line1' => 'Rue Royale 12',
'city' => 'Brussels',
'postCode' => '1000',
'country' => 'BE',
],
],
'buyer' => [
'name' => 'Antwerp Industries NV',
'postalAddress' => [
'line1' => 'Meir 45',
'city' => 'Antwerp',
'postCode' => '2000',
'country' => 'BE',
],
],
'paymentDetails' => [
'paymentAccountIdentifier' => 'BE68539007547034',
],
'lines' => [[
'quantity' => 100,
'item' => ['name' => 'Software development services'],
'priceDetails' => ['netPrice' => 50.00],
'vatInformation' => ['rate' => 21],
]],
],
];
$ch = curl_init('https://api.invoicexml.com/v1/create/ubl');
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-peppol.xml', curl_exec($ch));
That is the whole request. The API computes totals, builds the VAT breakdown, stamps the Peppol BIS Billing 3.0 CustomizationID, and validates against EN 16931 plus the Peppol overlay before returning the UBL 2.1 XML. /v1/create/ubl emits Peppol BIS 3.0 by default. To pin a different CIUS, set invoice.specificationId to the target CustomizationID URI (for example urn:cen.eu:en16931:2017#compliant#urn:fdc:nen.nl:nlcius:v1.0 for NLCIUS).
Validate incoming UBL invoices in PHP
When suppliers send you UBL invoices through Peppol or via email attachment, validate them before importing into your ERP or accounting system. The validation endpoint runs the EN 16931 Schematron plus the appropriate Peppol overlay automatically based on the CustomizationID declared in the document.
<?php
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://api.invoicexml.com',
'headers' => [
'Authorization' => 'Bearer ' . getenv('INVOICEXML_API_KEY'),
],
]);
function validateUbl(Client $client, string $xmlPath): array
{
$response = $client->post('/v1/validate/ubl', [
'multipart' => [[
'name' => 'file',
'contents' => fopen($xmlPath, 'r'),
'filename' => basename($xmlPath),
]],
]);
return json_decode((string) $response->getBody(), true);
}
$result = validateUbl($client, 'supplier-invoice.xml');
if ($result['valid']) {
echo "Valid {$result['data']['conformanceLevel']} invoice\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.
The response carries a data block describing which validation layers passed (XSD and Schematron), plus flat errors and warnings arrays. Each finding is self-contained: the human-readable message, the business term codes (btCodes), the JSON paths into the document (fields), and the verbatim validator output (raw).
{
"valid": false,
"detail": "Validation failed with 2 error(s)",
"data": {
"schemaValid": true,
"schematronValid": false,
"conformanceLevel": "UBL 2.1"
},
"errors": [
{
"rule": "BR-CO-14",
"line": null,
"message": "Invoice total VAT amount does not match the sum of VAT breakdown amounts.",
"btCodes": ["BT-110"],
"fields": ["totals.taxTotalAmount"],
"raw": "[BR-CO-14] EN16931: ... (at /...)"
},
{
"rule": "PEPPOL-EN16931-R004",
"line": null,
"message": "Invoice type code should be 380 for a standard invoice.",
"btCodes": ["BT-3"],
"fields": ["typeCode"],
"raw": "[PEPPOL-EN16931-R004] Peppol-BIS: ... (at /...)"
}
],
"warnings": []
}
The raw string identifies the rule's origin (EN 16931 base vs. Peppol BIS overlay) by its prefix. Rules with BR- codes come from the EN 16931 base; rules with PEPPOL- codes come from the Peppol overlay. This matters operationally: an EN 16931 finding means the invoice data is wrong and goes back to the supplier, a Peppol finding means the document declares an incorrect network profile and goes to your access point provider.
Convert between CII and UBL syntax
The two EN 16931 syntax bindings carry the same data in different XML shapes. Converting between them is needed when bridging ZUGFeRD-based and Peppol-based ecosystems in a single workflow.
<?php
// UBL to CII
function ublToCii(Client $client, string $ublPath): string
{
$response = $client->post('/v1/convert/ubl/to/cii', [
'multipart' => [[
'name' => 'file',
'contents' => fopen($ublPath, 'r'),
'filename' => basename($ublPath),
]],
]);
return (string) $response->getBody();
}
// CII to UBL
function ciiToUbl(Client $client, string $ciiPath): string
{
$response = $client->post('/v1/convert/cii/to/ubl', [
'multipart' => [[
'name' => 'file',
'contents' => fopen($ciiPath, 'r'),
'filename' => basename($ciiPath),
]],
]);
return (string) $response->getBody();
}
This is the missing piece in most PHP UBL libraries. A German supplier sending ZUGFeRD (CII inside PDF/A-3b) to a Belgian customer who can only accept Peppol UBL needs the data converted on the way through.
Render UBL as a human-readable PDF
UBL XML is not designed for human reading. For AP approval workflows where finance teams need to review invoices before payment, render the XML as a clean PDF:
<?php
function renderUblAsPdf(Client $client, string $xmlPath): string
{
$response = $client->post('/v1/render/ubl/to/pdf', [
'multipart' => [[
'name' => 'file',
'contents' => fopen($xmlPath, 'r'),
'filename' => basename($xmlPath),
]],
]);
return (string) $response->getBody();
}
file_put_contents('invoice-preview.pdf', renderUblAsPdf($client, 'peppol-invoice.xml'));
The rendered PDF shows the detected Peppol profile, all line items, the VAT breakdown, payment details, and IBAN. Everything the approver needs without ever opening the underlying XML.
For ingestion into ERP or AP systems that prefer JSON over XML, extract the full invoice data:
<?php
function extractUblToJson(Client $client, string $xmlPath): array
{
$response = $client->post('/v1/extract/json', [
'multipart' => [[
'name' => 'file',
'contents' => fopen($xmlPath, 'r'),
'filename' => basename($xmlPath),
]],
]);
return json_decode((string) $response->getBody(), true);
}
$data = extractUblToJson($client, 'incoming-invoice.xml');
echo "Invoice {$data['invoiceNumber']} from {$data['seller']['name']}\n";
echo "Total: {$data['totals']['grandTotalAmount']} {$data['currency']}\n";
echo "Lines: " . count($data['lines']) . "\n";
The structured JSON output makes it straightforward to insert directly into a relational database or pass to downstream services without XML parsing in your PHP code.
Laravel integration: service class with dependency injection
For Laravel applications, wrap the InvoiceXML operations in a service class and bind it as a singleton:
<?php
// app/Services/PeppolUblService.php
namespace App\Services;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Storage;
class PeppolUblService
{
public function __construct(private Client $http) {}
public function generate(array $invoice): string
{
$response = $this->http->post('/v1/create/ubl', [
'json' => ['invoice' => $invoice],
]);
return (string) $response->getBody();
}
public function validate(string $ublXml): array
{
$response = $this->http->post('/v1/validate/ubl', [
'multipart' => [[
'name' => 'file',
'contents' => $ublXml,
'filename' => 'invoice.xml',
]],
]);
return json_decode((string) $response->getBody(), true);
}
public function generateAndStore(array $invoice, string $disk = 's3'): string
{
$ubl = $this->generate($invoice);
$validation = $this->validate($ubl);
if (!$validation['valid']) {
$errors = collect($validation['errors'])
->map(fn($e) => "[{$e['rule']}] {$e['message']}")
->implode("\n");
throw new \RuntimeException("Invoice failed validation:\n{$errors}");
}
$path = "invoices/peppol/{$invoice['invoiceNumber']}.xml";
Storage::disk($disk)->put($path, $ubl);
return $path;
}
}
Register in AppServiceProvider:
public function register(): void
{
$this->app->singleton(PeppolUblService::class, function ($app) {
return new PeppolUblService(new \GuzzleHttp\Client([
'base_uri' => 'https://api.invoicexml.com',
'headers' => [
'Authorization' => 'Bearer ' . config('services.invoicexml.key'),
],
'timeout' => 30,
]));
});
}
Inject it anywhere via constructor injection. Useful for queue jobs that process invoices in bulk, scheduled commands that generate monthly invoices, or HTTP controllers handling supplier UBL uploads.
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\PeppolUblService:
arguments:
$http: '@GuzzleHttp\Client'
Inject via constructor in any service or controller:
<?php
// src/Controller/PeppolController.php
namespace App\Controller;
use App\Service\PeppolUblService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class PeppolController extends AbstractController
{
public function __construct(private PeppolUblService $peppol) {}
#[Route('/invoices/{id}/peppol', methods: ['POST'])]
public function generatePeppolUbl(int $id): Response
{
$invoice = $this->loadInvoiceData($id);
$ublXml = $this->peppol->generate($invoice);
return new Response($ublXml, 200, [
'Content-Type' => 'application/xml',
'Content-Disposition' => "attachment; filename=\"{$invoice['invoiceNumber']}.xml\"",
]);
}
}
Production pattern: validate before submission
A typical Peppol workflow generates an invoice, validates it, and only then transmits it to the network. Combining the create and validate steps catches data errors before transmission rather than receiving a rejection from your Peppol access point:
<?php
class PeppolWorkflow
{
public function __construct(private PeppolUblService $peppol) {}
public function createValidatedInvoice(array $invoice): array
{
// Step 1: generate UBL
$ublXml = $this->peppol->generate($invoice);
// Step 2: validate before transmission
$validation = $this->peppol->validate($ublXml);
if (!$validation['valid']) {
$errors = array_map(
fn($e) => "[{$e['rule']}] {$e['message']}",
$validation['errors']
);
throw new \RuntimeException(
"Generated UBL failed validation:\n" . implode("\n", $errors)
);
}
return [
'xml' => $ublXml,
'conformanceLevel' => $validation['data']['conformanceLevel'],
'warnings' => $validation['warnings'],
];
}
}
The validation cost is minimal and the integration reliability improvement is significant.
Why use a REST API instead of building UBL in PHP
A few PHP libraries exist for UBL generation. num-num/ubl-invoice produces UBL XML from PHP objects. For basic UBL 2.1 generation without compliance validation, it works.
What InvoiceXML handles that PHP libraries do not:
EN 16931 Schematron validation across all 200+ base 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 consequences when submitted via a Peppol access point.
Peppol BIS 3.0 overlay validation. The Peppol-specific rules update quarterly from OpenPeppol. Updates are deployed before each new specification's effective date with no integration change required on your side. Your application keeps generating valid invoices through every standard revision.
National CIUS detection on validation. The validate endpoint reads the CustomizationID declared in the incoming UBL and applies the matching overlay automatically: Peppol BIS 3.0, NLCIUS, EHF, PINT variants, or XRechnung UBL. No routing logic needed on your side.
Bidirectional CII and UBL syntax conversion. Lossless, validated, and useful when your PHP application bridges ZUGFeRD-based and Peppol-based ecosystems. Most PHP UBL libraries cannot do this conversion at all.
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 UBL endpoint reference for PHP
| Operation | Endpoint | Input | Output |
| Create UBL | POST /v1/create/ubl | JSON invoice | UBL 2.1 XML |
| PDF to UBL (AI) | POST /v1/transform/to/ubl | PDF, image, CII XML | UBL 2.1 XML |
| Validate UBL | POST /v1/validate/ubl | UBL XML | Validation JSON |
| UBL to CII | POST /v1/convert/ubl/to/cii | UBL XML | CII XML |
| CII to UBL | POST /v1/convert/cii/to/ubl | CII XML | UBL XML |
| Extract as JSON | POST /v1/extract/json | UBL XML | Structured JSON |
| Render as PDF | POST /v1/render/ubl/to/pdf | UBL XML | PDF binary |
Full OpenAPI 3.1 schema: api.invoicexml.com/v1/openapi. Interactive API explorer: api.invoicexml.com/v1/scalar.
Get started
Create a free InvoiceXML account: includes 100 credits to get started, no credit card required.
Related resources:
InvoiceXML is a REST API for Peppol-ready e-invoice compliance covering UBL, CII, ZUGFeRD, Factur-X, and XRechnung. Stateless processing, GDPR compliant by architecture, and integration support from any PHP application: Laravel, Symfony, WooCommerce, PrestaShop, or custom platforms.