PeppolValidator is a comprehensive PHP class for validating PEPPOL BIS Billing 3.0 compliant XML documents (invoices and credit notes). It implements validation rules from the official PEPPOL specification and provides detailed error reporting with line number tracking.
Version: 1.1.0
License: MIT
Namespace: App\Invoice\Helpers\Peppol
- ✅ Complete PEPPOL BIS Billing 3.0 validation
- ✅ Line number tracking for errors and warnings
- ✅ XPath-based element location tracking
- ✅ Support for both Invoice and CreditNote documents
- ✅ Comprehensive error and warning reporting
- ✅ Multi-language support via TranslatorInterface
- ✅ ISO code list validation (countries, currencies, MIME types, etc.)
use App\Invoice\Helpers\Peppol\PeppolValidator;
use Yiisoft\Translator\TranslatorInterface;
$validator = new PeppolValidator($translator);// Load XML content
$xmlContent = file_get_contents('invoice.xml');
$validator->loadXML($xmlContent);
// Validate the document
$isValid = $validator->validate();
// Get validation results
if ($isValid) {
echo "Document is valid!";
} else {
$errors = $validator->getFormattedErrors();
foreach ($errors as $error) {
echo $error . "\n";
}
}
// Get detailed summary
$summary = $validator->getSummary();
print_r($summary);public function __construct(TranslatorInterface $t)Initializes the validator with a translator instance and sets up code lists (ISO 3166, ISO 4217, UNCL codes, etc.).
Loads and parses XML content for validation.
Parameters:
$xmlContent- The XML string to validate
Returns: true on successful load, false on XML parsing errors
Example:
$success = $validator->loadXML($xmlContent);
if (!$success) {
$errors = $validator->getErrors();
}Runs all validation rules against the loaded document.
Returns: true if document is valid (no errors), false otherwise
Validation Categories:
- Empty elements (PEPPOL-EN16931-R008)
- Document level rules
- Party information
- Allowances and charges
- Payment means
- Currency consistency
Checks if the document passed validation.
Returns: true if no errors exist
Returns raw error array with detailed information.
Returns: Array of error objects containing:
[
'rule' => string, // Rule identifier (e.g., "PEPPOL-EN16931-R001")
'text' => string, // Error message
'line' => int|null, // Line number in XML
'xpath' => string|null // XPath to the element
]Returns raw warning array with similar structure to errors.
Returns user-friendly formatted error messages.
Returns: Array of strings like:
[Line 42] PEPPOL-EN16931-R001: Business process MUST be provided (at /Invoice[1]/cbc:ProfileID[1])
Returns user-friendly formatted warning messages.
Returns comprehensive validation summary.
Returns:
[
'valid' => bool,
'error_count' => int,
'warning_count' => int,
'errors' => array,
'warnings' => array,
'document_type' => string|null, // 'Invoice' or 'CreditNote'
'profile' => string|null, // e.g., '01' for basic invoice
'supplier_country' => string|null, // ISO 3166 code
'customer_country' => string|null // ISO 3166 code
]| Rule ID | Description |
|---|---|
| PEPPOL-EN16931-R001 | Business process MUST be provided |
| PEPPOL-EN16931-R002 | Max one note allowed (unless both parties are DE) |
| PEPPOL-EN16931-R003 | Buyer reference or order reference required |
| PEPPOL-EN16931-R004 | Specification identifier required and valid format |
| PEPPOL-EN16931-R005 | Tax currency must differ from document currency |
| PEPPOL-EN16931-R007 | Business process format validation |
| PEPPOL-EN16931-R008 | Empty elements not allowed |
| Rule ID | Description |
|---|---|
| PEPPOL-EN16931-R053 | One tax total with subtotals required |
| PEPPOL-EN16931-R054 | Tax total without subtotals validation |
| PEPPOL-EN16931-R055 | Tax amounts must have same sign |
| Rule ID | Description |
|---|---|
| PEPPOL-EN16931-R040 | Amount must equal base × percentage/100 |
| PEPPOL-EN16931-R041 | Base amount required when percentage provided |
| PEPPOL-EN16931-R042 | Percentage required when base amount provided |
| PEPPOL-EN16931-R043 | Charge indicator must be true or false |
| PEPPOL-EN16931-R044 | Price level charge not allowed |
| PEPPOL-EN16931-R046 | Item net price calculation validation |
| Rule ID | Description |
|---|---|
| PEPPOL-EN16931-R010 | Buyer electronic address required |
| PEPPOL-EN16931-R020 | Seller electronic address required |
| Rule ID | Description |
|---|---|
| PEPPOL-EN16931-R061 | Mandate reference required for direct debit |
| Rule ID | Description |
|---|---|
| PEPPOL-EN16931-R051 | All amounts must use document currency |
| Rule ID | Description |
|---|---|
| PEPPOL-EN16931-R080 | Max one project reference allowed |
The validator includes the following code lists for validation:
- ISO 3166: Country codes (all standard codes including 1A, XI)
- ISO 4217: Currency codes (all standard currency codes)
- MIME Codes: Supported attachment types
- application/pdf
- image/png
- image/jpeg
- text/csv
- application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
- application/vnd.oasis.opendocument.spreadsheet
- UNCL 2005: Date/time format codes (3, 35, 432)
- UNCL 5189: Allowance/charge reason codes
- UNCL 7161: Special service codes
- EAID: Electronic address scheme codes
$validator->loadXML($xmlContent);
$validator->validate();
$summary = $validator->getSummary();
foreach ($summary['errors'] as $error) {
$ruleId = $error['rule'];
$message = $error['text'];
$line = $error['line'] ?? 'unknown';
$xpath = $error['xpath'] ?? 'N/A';
// Custom logging or processing
error_log("Validation Error [$ruleId] at line $line: $message");
}$validator->loadXML($xmlContent);
$validator->validate();
$errors = $validator->getErrors();
$r001Errors = array_filter($errors, function($error) {
return str_starts_with($error['rule'], 'PEPPOL-EN16931-R001');
});
if (!empty($r001Errors)) {
echo "Missing business process ID";
}class InvoiceController
{
public function validateAction(Request $request)
{
$xmlContent = $request->getContent();
$validator = new PeppolValidator($this->translator);
if (!$validator->loadXML($xmlContent)) {
return new JsonResponse([
'success' => false,
'error' => 'Invalid XML format'
], 400);
}
$isValid = $validator->validate();
$summary = $validator->getSummary();
return new JsonResponse([
'success' => $isValid,
'summary' => $summary,
'formatted_errors' => $validator->getFormattedErrors()
]);
}
}extractDocumentVariables()- Extracts document type, profile, countries, and currencyextractSupplierCountry()- Determines supplier country from VAT or addressextractCustomerCountry()- Determines customer country from VAT or address
validateEmptyElements()- Checks for empty XML elementsvalidateDocumentLevel()- Validates document-level requirementsvalidateNoteRestrictions()- Validates note count based on country rulesvalidateBuyerReference()- Ensures buyer reference or order reference existsvalidateCustomizationID()- Validates specification identifier formatvalidateTaxTotals()- Validates tax total structure and amountsvalidateTaxAmountsSameSign()- Ensures tax amounts have consistent signsvalidateParties()- Validates party informationvalidateAllowanceCharge()- Validates all allowances and chargesvalidateSingleAllowanceCharge()- Validates individual allowance/chargevalidateAllowanceChargeCalculation()- Validates amount calculationsvalidatePriceLevelAllowances()- Validates price-level allowancesvalidatePriceCalculation()- Validates price calculationsvalidatePaymentMeans()- Validates payment method requirementsvalidateCurrency()- Validates currency consistencyvalidateAmountCurrency()- Validates individual amount currencies
getNodeValue()- Retrieves text value from XPath querygetNode()- Retrieves DOM node from XPath querygetNodeXPath()- Builds XPath expression for a given nodegetXMLErrors()- Formats libxml parsing errorsaddError()- Adds error with line trackingaddWarning()- Adds warning with line tracking
The validator registers and uses the following namespaces:
'cbc' => 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2'
'cac' => 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'
'ubl-invoice' => 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2'
'ubl-creditnote' => 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2'Each error contains:
[
'rule' => 'PEPPOL-EN16931-RXXX', // Rule identifier (split on first ': ')
'text' => 'Description of error', // Remainder of the message
'line' => '42', // DOM line number as string (or null)
'xpath' => '/Invoice[1]/...' // XPath location (or null)
]Each warning contains:
[
'message' => 'PEPPOL-COMMON-R042: ...', // Full message string
'line' => 42, // DOM line number as int (or null)
'xpath' => '/Invoice[1]/...' // XPath location (or null)
]- PHP 8.0+
- DOM extension
- libxml extension
- Yiisoft\Translator\TranslatorInterface
The validator uses translation keys for error messages. Example keys:
peppol.unknown.document.typePEPPOL.EN16931.R001PEPPOL.EN16931.R002PEPPOL.EN16931.R003- etc.
Ensure your translator has these keys defined for proper internationalization.
- The validator loads the entire XML document into memory
- XPath queries are used extensively for element lookup
- For large batches, consider reusing the validator instance:
$validator = new PeppolValidator($translator);
foreach ($xmlFiles as $file) {
$validator->loadXML(file_get_contents($file));
$validator->validate();
// Process results
}- Only validates PEPPOL BIS Billing 3.0 format
- Does not validate against XSD schemas (focuses on business rules)
- Does not validate mathematical totals (line item sums, tax calculations, etc.)
- Limited to Invoice and CreditNote document types
- Always check loadXML() return value before calling validate()
- Use getSummary() for comprehensive validation results
- Implement proper error logging for production environments
- Cache validator instances when validating multiple documents
- Provide translation keys for all supported languages
- Handle both errors and warnings in your application logic
use App\Invoice\Helpers\Peppol\PeppolValidator;
function validatePeppolInvoice(string $xmlPath, TranslatorInterface $translator): array
{
$validator = new PeppolValidator($translator);
// Step 1: Load XML
$xmlContent = file_get_contents($xmlPath);
if (!$validator->loadXML($xmlContent)) {
return [
'success' => false,
'stage' => 'load',
'errors' => $validator->getFormattedErrors()
];
}
// Step 2: Validate
$isValid = $validator->validate();
// Step 3: Get results
$summary = $validator->getSummary();
return [
'success' => $isValid,
'stage' => 'validation',
'summary' => $summary,
'formatted_errors' => $validator->getFormattedErrors(),
'formatted_warnings' => $validator->getFormattedWarnings()
];
}
// Usage
$result = validatePeppolInvoice('invoice.xml', $translator);
if ($result['success']) {
echo "✓ Invoice is PEPPOL compliant\n";
} else {
echo "✗ Validation failed:\n";
foreach ($result['formatted_errors'] as $error) {
echo " - $error\n";
}
}- Ensure XML is well-formed
- Check for encoding issues (UTF-8 recommended)
- Validate against UBL XSD schemas first
- Implement fallback translations
- Check translator configuration
- Verify all PEPPOL rule keys are defined
- Ensure
preserveWhiteSpace = falsein DOMDocument - Check that original XML has proper line breaks
- Some XML minifiers may remove line number information
MIT License - See project license file for details.
- 1.1.0 - Added complete line number tracking and XPath support
- 1.0.0 - Initial release with core PEPPOL validation rules
This section documents the private design of PeppolValidator for
contributors. See also
PEPPOL_SCHEMATRON_CODEGEN.md for the
code-generation pipeline that produces additional validators from the
Schematron .sch file.
new PeppolValidator($translator)
│
▼
loadXML($xmlContent)
├── DOMDocument::loadXML()
├── DOMXPath namespace registration
└── extractDocumentVariables() ← sets the five state fields below
│
▼
validate()
├── validateUBLVersion()
├── validateEmptyElements() R008
├── validateDocumentLevel() R004, R005, R007, R053, R054, R055, R080
├── validateParties() R010, R020
├── validateAllowanceCharge() R040–R046
├── validatePaymentMeans() R061
├── validateCurrency() R051
├── validateCodeLists() BR-CL-xx
├── validateWithCalculators() delegates to Calculator classes
└── validateWithRegistry() delegates to ValidationRule classes (R001–R003)
The five private state fields are set once by extractDocumentVariables()
and treated as read-only throughout validate().
What: The two-digit Peppol business-process identifier extracted from
cbc:ProfileID, e.g. '01' for BIS Billing 3.0
(urn:fdc:peppol.eu:2017:poacc:billing:01:1.0).
Why: The profile URI identifies which Peppol process governs this
invoice. If it is absent or does not match the expected URI pattern,
rule R001 (via RuleRegistry) and R007 (inline in
validateDocumentLevel) will fire.
How: The full cbc:ProfileID string is matched against
/urn:fdc:peppol\.eu:2017:poacc:billing:(\d{2}):1\.0/. On a match,
$profile gets the two-digit capture group (e.g. '01'). If the
element is absent or the pattern does not match, $profile is set to
the translated 'reason.unknown' string, which is what triggers R007.
What: Two-letter ISO 3166-1 alpha-2 country code of the invoice
seller, e.g. 'GB' or 'DE'.
Why: Several Peppol rules are country-specific. Currently R002
(max one document-level cbc:Note) is relaxed when both supplier and
customer are in Germany — DE-to-DE invoices may carry more than one note.
More country-pair rules may be added in future Schematron versions.
How (fallback chain, first match wins):
| Priority | Source |
|---|---|
| 1 | First two chars (uppercased) of the seller's VAT cbc:CompanyID via //cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme[TaxScheme/ID='VAT']/cbc:CompanyID |
| 2 | First two chars (uppercased) of the tax representative's VAT cbc:CompanyID via //cac:TaxRepresentativeParty/…/cbc:CompanyID |
| 3 | cbc:IdentificationCode inside the seller's cac:PostalAddress/cac:Country |
| 4 | 'XX' (unknown) |
What: Two-letter ISO 3166-1 alpha-2 country code of the invoice
buyer, e.g. 'GB' or 'DE'.
Why: Required alongside $supplierCountry for country-pair rules —
currently only the DE-to-DE relaxation in R002.
How (fallback chain, first match wins):
| Priority | Source |
|---|---|
| 1 | First two chars (uppercased) of the buyer's VAT cbc:CompanyID via //cac:AccountingCustomerParty/cac:Party/…/cbc:CompanyID |
| 2 | cbc:IdentificationCode inside the buyer's cac:PostalAddress/cac:Country |
| 3 | 'XX' (unknown) |
What: Three-letter ISO 4217 currency code from
cbc:DocumentCurrencyCode, e.g. 'GBP' or 'EUR'.
Why: Every monetary amount in a Peppol invoice must use the document currency (rule R051). The same value is checked by BR-CL-04 (currency in the allowed code list) and R005 (tax currency must differ from document currency when both are present).
How: Retrieved directly from //cbc:DocumentCurrencyCode via
getNodeValue(); stored as a trimmed string, or null if the element
is absent.
What: 'Invoice' or 'CreditNote', detected from the document's
root namespace.
Why: Some rules apply to only one document type. R080 (max one
AdditionalDocumentReference with DocumentTypeCode = '50') is
credit-note-only. A null value means the root element was not
recognised; validate() returns false immediately in that case.
How: The document is queried for //ubl-invoice:Invoice and for
//ubl-creditnote:CreditNote. The first match sets the field; if
neither is found it remains null.
validateWithRegistry() instantiates a RuleRegistry, registers
concrete ValidationRule implementations (currently R001–R003), runs
them all, and fans the resulting ValidationViolation objects into
$this->errors (Fatal) or $this->warnings (Warning/Info).
To add a new rule: implement ValidationRule, extend AbstractRule
for the DOM query helpers (queryValue, queryNode, fatal, warn),
and add one $registry->register(new MyRule($this->t)) line to
validateWithRegistry().
validateWithCalculators() delegates three groups of arithmetic checks:
| Class | Responsibility |
|---|---|
MonetaryTotalCalculator |
LegalMonetaryTotal field cross-checks |
TaxCalculator |
Tax subtotal and rounding checks |
InvoiceLineCalculator |
Per-line amount cross-checks |
Each calculator exposes validate() + getErrors() and is
instantiated fresh on every validate() call.