ayoubgaouet / tn-einvoice-validator
Validate Tunisian TEIF electronic invoices (TTN El Fatoora) in pure PHP. Implements the official XSD 1.8.8, the Guide d'Implémentation TEIF V2.0 and the XAdES-B signature specification, with error messages in English, French and Arabic. No Java required.
Package info
github.com/ayoubgaouet/tn-einvoice-validator
pkg:composer/ayoubgaouet/tn-einvoice-validator
Requires
- php: ^8.2
- ext-dom: *
- ext-libxml: *
- ext-mbstring: *
Requires (Dev)
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.5 || ^12.0 || ^13.0
Suggests
- illuminate/support: Required only for the Laravel service provider and facade (^12.0 || ^13.0).
README
Validate Tunisian TEIF electronic invoices (TTN El Fatoora) in pure PHP.
This package replaces the reference Java validator. It implements TTN's official XSD 1.8.8, the rules the Guide d'Implémentation TEIF V2.0 states but the schema does not enforce, and the XAdES-B structure required by the Spécifications Techniques de la Signature Fournisseur V3.0 — with messages in English, French and Arabic.
No Java, JDK, Maven or JAR is required at runtime.
$result = XmlValidator::validate($xml, locale: 'fr'); if (! $result->isValid()) { foreach ($result->errors() as $error) { report($error->code, $error->path, $error->message); } }
Contents
- Why
- Installation
- Configuration
- Basic usage
- Validating a string, a file, a document
- Validation result
- Errors and warnings
- Error codes
- Languages
- Reference data
- What gets validated
- Rules the XSD does not enforce
- Where the Guide and the XSD disagree
- Inferred rules
- Signature validation
- Parity with the Java validator
- Laravel integration
- Custom rules
- Testing
- Versioning
- Compatibility
- Security
- License
Why
TTN publishes its schema in XSD 1.1, and Xerces is effectively the only
implementation of it. Validating a TEIF invoice has therefore meant running the
schema through a Java toolchain: export the XML, hand it to a Java validator,
read the stack trace, come back, fix, repeat. A bare Xerces validator also stops
at the first SAXParseException, so a document with eight problems takes
eight round trips.
And it only ever runs the schema. A large part of the specification — the payment referentials, the date formats, the signature structure — is written in the Guide but absent from the XSD, so no schema validator can see any of it.
This package removes the loop and closes those gaps. Validation happens in process, every layer that can run does run, and one call reports everything.
Installation
composer require ayoubgaouet/tn-einvoice-validator
Laravel discovers the service provider and the XmlValidator facade
automatically. Nothing else is needed to start validating.
Configuration
The defaults work out of the box. To change them:
php artisan vendor:publish --tag=xml-validator-config
// config/xml-validator.php return [ 'profile' => env('TEIF_VALIDATOR_PROFILE', 'unsigned'), 'locale' => env('TEIF_VALIDATOR_LOCALE', 'fr'), 'schemas' => [ 'unsigned' => __DIR__.'/../resources/xsd/teif-1.8.8-withoutSig.xsd', 'signed' => __DIR__.'/../resources/xsd/teif-1.8.8-withSig.xsd', ], 'rules' => [ /* see "Custom rules" */ ], 'collect_warnings' => true, ];
There are two profiles:
| Profile | Use for |
|---|---|
unsigned (default) |
invoices you are generating, before signature |
signed |
invoices carrying ds:Signature |
The signed schema requires at least one ds:Signature, so an unsigned invoice
will not validate against it, and vice versa.
Publishable tags: xml-validator-config, xml-validator-lang,
xml-validator-schemas. None of them is required — the package works unpublished.
Basic usage
Outside Laravel:
use TnEfacture\XmlValidator\XmlValidator; $result = XmlValidator::make()->validate($xml);
Inside Laravel, via the facade:
use TnEfacture\XmlValidator\Facades\XmlValidator; $result = XmlValidator::validate($xml);
or through the container, which is easier to fake in tests:
public function __construct(private readonly XmlValidator $validator) {}
Validating a string, a file, a document
XmlValidator::validate($xml); XmlValidator::validate($xml, 'signed'); XmlValidator::validate($xml, locale: 'ar'); XmlValidator::validateFile(storage_path('invoices/2026-000123.xml')); // Skips a serialise/reparse round trip when you just built the invoice. XmlValidator::validateDocument($document);
validateFile() throws UnreadableFileException if the path cannot be read.
Findings on a programmatically built DOMDocument carry a path but no line,
since line numbers only exist for documents parsed from text.
Validation result
$result->isValid(); $result->isInvalid(); $result->hasErrors(); $result->hasWarnings(); $result->errors(); // list<ValidationError> $result->warnings(); // never affect validity $result->all(); // errors then warnings $result->first(); // ?ValidationError $result->codes(); // distinct error codes $result->hasError(ErrorCode::E_MF_001); $result->count(); // number of errors $result->profile(); // 'unsigned' | 'signed' $result->locale(); // 'en' | 'fr' | 'ar' $result->translate('ar'); $result->validated(); // the XML that was validated $result->document(); // ?DOMDocument $result->toArray(); $result->toJson();
The result is Countable and iterable over its errors:
foreach ($result as $error) { logger()->warning((string) $error); }
Errors and warnings
$error->code; // stable, language-independent — branch on this $error->message; // translated $error->severity; // Severity::Error | Severity::Warning $error->path; // /TEIF/InvoiceBody/.../PartnerIdentifier $error->line; $error->column; $error->value; // the offending value $error->element; // local element name $error->source; // the specification clause behind the rule $error->parameters;
$result->toArray():
[
'valid' => false,
'profile' => 'unsigned',
'locale' => 'fr',
'errors' => [
[
'code' => 'E_MF_001',
'message' => "L'identifiant « 1427326WAM00 » déclaré avec type=« I-01 » n'est pas un matricule fiscal tunisien valide.",
'severity' => 'error',
'path' => '/TEIF/InvoiceHeader/MessageSenderIdentifier',
'line' => 4,
'column' => null,
'value' => '1427326WAM00',
'element' => 'MessageSenderIdentifier',
'source' => 'XSD 1.8.8 xs:assert / Guide TEIF V2.0 §5.2.1.1',
],
],
'warnings' => [],
]
Errors make a document invalid. Warnings never do — they carry advice, inferred checks, and explanations of where TTN's schema contradicts TTN's Guide.
Errors are ordered by line, so they read top to bottom like the file itself. Paths add positional predicates only where a name is ambiguous among siblings:
/TEIF/InvoiceBody/PartnerSection/PartnerDetails[2]/Nad/PartnerIdentifier
/TEIF/InvoiceBody/Bgm/DocumentType/@code
Error codes
Codes are the public contract. Branch on these, never on message text.
E_ is an error, W_ is a warning. The full table, with the specification
clause behind each rule, is in docs/TRACEABILITY.md.
| Family | Covers |
|---|---|
E_XML_001..003 |
well-formedness, empty input, non-UTF-8 encoding |
E_XSD_001..009 |
the TEIF schema: root, attributes, sequence, code lists, patterns, lengths |
E_MF_001, W_MF_002 |
tax identifiers (matricule fiscal, CIN, carte de séjour) |
E_REF_001..003 |
payment referentials I-11, I-12, I-13 |
E_DATE_001..003 |
date formats, calendar validity, period ordering |
E_NUM_001..002 |
tax rates and percentages |
E_SIG_001..009 |
XAdES-B signature structure |
W_AMT_001..003 |
amount coherence (inferred) |
W_STRUCT_001, W_DOC_001, W_SPEC_001 |
structural advice, Guide/XSD gaps |
Every code carries its provenance at runtime:
use TnEfacture\XmlValidator\ReferenceData\RuleCatalogue; RuleCatalogue::source('E_DATE_001'); // 'Guide §5.4.4 (Tableau 24)' RuleCatalogue::isInferred('W_AMT_001'); // true
Languages
English, French and Arabic ship with the package. Codes never vary by language; only the message does.
XmlValidator::validate($xml, locale: 'fr'); XmlValidator::validate($xml, locale: 'ar'); // Or re-render an existing result without revalidating: $result->translate('ar');
en The identifier "1427326WAM00" declared with type="I-01" is not a valid Tunisian tax identifier (matricule fiscal).
fr L'identifiant « 1427326WAM00 » déclaré avec type=« I-01 » n'est pas un matricule fiscal tunisien valide.
ar المعرّف «1427326WAM00» المصرّح به بالنوع «I-01» ليس معرّفًا جبائيًّا تونسيًّا صالحًا.
The French wording uses the specification's own vocabulary (matricule fiscal, clef de contrôle, référentiel); the Arabic uses the terms used officially in Tunisia (المعرّف الجبائي, الإمضاء الإلكتروني). Even the schema errors are translated: libxml's English prose is parsed back into structured parameters and re-rendered, rather than passed through.
To override any message:
php artisan vendor:publish --tag=xml-validator-lang
Reference data
All 18 official code lists from Annexe A of the Guide (136 codes) are available, with their specification labels:
use TnEfacture\XmlValidator\ReferenceData\Referentials; Referentials::codes(Referentials::I_13); // ['I-131', ..., 'I-137'] Referentials::label('I-16', 'I-1602'); // 'TVA' Referentials::has('I-13', 'I-999'); // false Referentials::name('I-13'); // 'Payment Means' Referentials::all();
Useful for populating dropdowns from the same source the validator checks against, so your UI and your validation cannot drift apart.
What gets validated
XML well-formed and UTF-8 → E_XML_*
↓
conforms to the TEIF XSD → E_XSD_*
↓
business rules → E_MF_*, E_REF_*, E_DATE_*, E_NUM_*, E_SIG_*
↓
coherence advice → W_AMT_*, W_STRUCT_*, W_SPEC_*
↓
valid TEIF invoice
Every layer that can run does run, so one call reports everything.
TTN publishes its schema in XSD 1.1, which PHP's libxml does not implement. Rather than ship a weaker schema, the 1.1 constructs are split in two:
- Everything expressible in XSD 1.0 — 56 complex types, 30 simple types, 161 enumerations, 4 patterns, all length and occurrence constraints — stays in the schema and is enforced by libxml.
- The four XSD 1.1 constructs (2
xs:assert, 2xs:alternative) are enforced by PHP rules.
tools/downgrade-xsd.php performs that split. It is a script, not a hand edit,
so a future TTN revision can be re-processed reproducibly and the delta against
the official schema stays auditable. It fails loudly if the constructs it expects
are not found. The untouched originals ship alongside the generated files under
resources/xsd/.
Using TTN's original schema directly
You can point the schemas config at TTN's published XSD 1.1 files, exactly
as downloaded, and it just works:
'schemas' => [ 'unsigned' => '/path/to/facture_INVOIC_V1.8.8_withoutSig.xsd', 'signed' => '/path/to/facture_INVOIC_V1.8.8_withSig.xsd', ],
SchemaCompiler detects the 1.1 constructs and downgrades the schema in memory,
using the same transformation that produced the bundled files; nothing is written
to disk, and the compiled schema is cached per path and modification time. The
signed schema's remote xmldsig import is redirected to the vendored copy, so
validation still never touches the network.
This matters because libxml does not ignore XSD 1.1 constructs — it refuses to build the schema at all, reporting three opaque "The content is not valid" errors and validating nothing. A test asserts that all 55 fixtures produce identical verdicts whether validated against the original 1.1 schemas or the generated 1.0 ones.
Rules the XSD does not enforce
These come from the Guide but have no equivalent in the schema, so a schema-only validator cannot see them. They are reported as errors.
| Rule | Why the XSD misses it | Source |
|---|---|---|
PaymentTearmsTypeCode ∈ I-11 |
typed NotNullDataStringType_6, no enumeration |
Guide §5.6.4.1.1 |
PaiConditionCode ∈ I-12 |
same | Guide §5.6.7.2 |
PaiMeansCode ∈ I-13 |
same | Guide §5.6.7.3 |
A date matches its @format |
DateText is typed as a plain string |
Guide §5.4.4 |
| A date is a real calendar date | same | Guide §5.4.4 |
| A period does not end before it starts | same | Guide §5.4.4 |
TaxRate is numeric |
typed NotNullDataStringType_5, no pattern |
Guide §5.10.1.2 |
Percentage is numeric |
same | Guide §5.8.1.9 |
| The encoding is UTF-8 | no schema can express this | Signature V3.0 §8 |
| The XAdES-B signature structure | out of scope for the TEIF schema | Signature V3.0 §5 |
Concretely: <DateText functionCode="I-31" format="ddMMyy">HELLO!</DateText> and
<PaiMeansCode>ZZZ</PaiMeansCode> are both schema-valid. This package rejects
both.
Where the Guide and the XSD disagree
The official package contradicts itself in several places. In every case the XSD decides validity — it is what TTN's platform runs — and a warning explains the divergence, so a puzzling rejection becomes a precise statement.
The matricule fiscal
Guide §5.2.1.1 decomposes it into five parts. The XSD's xs:assert is narrower
on three of them:
| Part | Guide §5.2.1.1 | XSD assertion |
|---|---|---|
| Identifier | 7 digits | [0-9]{7} ✓ |
| Control key | A–Z except I, O, U | same ✓ |
| VAT code | A, P, B, F, N | A, B, D, N, P |
| Category code | M, P, C, N, E | C, M, N, P |
| Establishment no. | 000, 001, 002… |
000 only |
So a forfaitaire taxpayer (F) or a secondary establishment
(…E001) has a legitimate identifier that TTN's schema rejects. You get
E_MF_001 (it will be rejected) plus W_MF_002 naming exactly which part
diverges. The Guide's cross-field rule — an establishment number other than
000 goes with category E — is applied when deciding whether the warning is
warranted.
The XSD's own PatternOf_TNMF allows E, contradicting its assertion.
Codes absent from the schema
Five codes are defined in Annexe A but missing from the XSD enumerations:
I-69 (Inspecteur), I-818 (Numéro CNSS), I-871 (Nom marché public),
I-1604 (Retenue à la source), I-189 (Montant total HT toutes charges
comprises). Using one produces E_XSD_004 plus W_SPEC_001, which says the code
is official rather than invented.
Cardinalities
Guide Tableau 4 marks Pyt and LinDtm mandatory; the XSD makes both optional.
Pyt is reported as W_STRUCT_001. LinDtm is not reported at all: TTN's
own published example has none, so two official artefacts contradict the prose,
and enforcing it would fire on conforming invoices.
nameType
The Guide binds it to referential I-7 (I-71, I-72); the XSD enumerates
Physical and Qualification. The XSD wins — those are the values TTN accepts.
The official example does not validate against the official schema
exemple_signe_elfatoora.xml places RefTtnVal between two signatures, but the
XSD requires every ds:Signature last. Both this package and Xerces reject it at
the same line, for the same reason. The example reflects the real
flow (supplier signs → TTN adds its reference → TTN signs), which the schema
cannot express.
Inferred rules
Three checks are not mandated by any specification and are therefore warnings.
The Guide defines what each amount type means (Annexe A, referential I-17) but states no formula: it contains no mention of a sum, a total to reconcile, or a rounding method. The relations below are derived from those definitions, and hold exactly in TTN's published example:
I-176 (total HT) 2.000 I-177 (tax base) 2.000
I-178 (tax) 0.240 2.000 × 12% = 0.240
I-180 (total TTC) 2.540 = 2.000 + 0.240 + 0.300 (stamp duty)
| Code | Check |
|---|---|
W_AMT_001 |
total TTC = total HT + taxes |
W_AMT_002 |
tax amount = base × rate |
W_AMT_003 |
invoice total HT = sum of line totals |
They are still worth having. A swapped I-177/I-178 pair is a real defect that
TTN's schema will happily accept — and it is common, because the two codes are
easy to transpose.
Rounding tolerance is 0.001. The specification prescribes no rounding method, so none is assumed.
Signature validation
SignatureStructureRule checks the XAdES-B structure from Signature V3.0 §5:
the SigFrs identifier, exclusive canonicalisation, RSA-SHA256, SHA-256 digests,
the r-id-frs reference with its three transforms, the reference to
xades:SignedProperties, the certificate in ds:KeyInfo, and a valid
xades:SigningTime. It also enforces §2: a submitted invoice carries exactly
one signature.
It does not recompute digests or verify the certificate chain. That needs canonicalisation and revocation checking, and TTN performs it on receipt. What it catches is the large majority of integration mistakes — the wrong algorithm, a missing transform, a signature built with the wrong Id.
A document can therefore be structurally valid here and still carry a cryptographically invalid signature.
Parity with the Java validator
Parity is measured, not asserted. tools/capture-java-parity.php runs every
fixture through Xerces 2.12.2 (the xml-schema-1.1 classifier, the reference
XSD 1.1 implementation) against TTN's untouched schemas, records both verdicts in
tests/Fixtures/java-parity.json, and ParityTest replays them. The oracle it
uses is tools/java-oracle/Oracle.java, in this repository; everything it needs
is public, so anyone can reproduce the comparison.
Across 55 fixtures: 40 identical verdicts, 15 declared divergences — every one a case where this package is stricter, for a documented reason.
Two invariants are enforced by the test suite:
- Verdicts agree unless the divergence is declared with a type and a
justification (
spec_ruleorschema_gap). - This package is never more permissive than Java. Being stricter is the point; accepting something Java rejects would mean an invoice passes here and is rejected by TTN.
The single schema_gap divergence: a PartnerDetails with no functionCode
matches neither xs:alternative, so under XSD 1.1 it falls back to xs:anyType
and its entire subtree escapes validation. This is observable — injecting
arbitrary content under such an element still yields {"valid":true} from the
Java validator. Since functionCode is use="required" in both branches, this
package reports it.
Running the tests needs no Java; Java is only needed to regenerate the manifest.
Two behavioural improvements, neither of which changes a verdict:
- A bare Xerces validator throws on the first error. This package collects all of them.
- Xerces emits two diagnostics per bad value (the failing facet, then a wrapper
such as
cvc-attribute.3). This package reports one finding per problem: on a document with six seeded errors, Xerces produces eleven diagnostics and this package produces six, on exactly the same six lines.
Laravel integration
before: Laravel → XML → export by hand → Java validator → read stack trace → back to Laravel
after: Laravel → XmlValidator → errors, in process
use TnEfacture\XmlValidator\Facades\XmlValidator; public function store(InvoiceRequest $request): JsonResponse { $xml = $this->generator->generate($request->validated()); $result = XmlValidator::validate($xml, locale: app()->getLocale()); if ($result->isInvalid()) { return response()->json([ 'message' => "La facture générée n'est pas conforme au format TEIF.", 'errors' => $result->toArray()['errors'], ], 422); } return response()->json(['xml' => $xml]); }
As a validation rule:
Validator::extend('teif', fn ($attribute, $value) => XmlValidator::validate($value)->isValid());
In a test:
$this->assertTrue(XmlValidator::validate($xml)->isValid());
Custom rules
Implement Rule and register the class in config/xml-validator.php:
namespace App\Validation; use DOMDocument; use TnEfacture\XmlValidator\DTO\ValidationError; use TnEfacture\XmlValidator\Rules\Rule; use TnEfacture\XmlValidator\Support\Translator; final class PurchaseOrderRule implements Rule { public function name(): string { return 'purchase_order'; } public function validate(DOMDocument $document, Translator $translator): array { // ... your check ... return []; } }
'rules' => [ ...TnEfacture\XmlValidator\XmlValidator::DEFAULT_RULES, App\Validation\PurchaseOrderRule::class, ],
Rules run against a well-formed document whether or not the schema pass succeeded, so their findings appear alongside schema errors in one result.
Removing a bundled rule silently stops its checks, so drop one only deliberately.
Testing
composer install
composer test
415 tests, 1280 assertions. Coverage includes each schema error category, every branch of the tax identifier rule (positive and negative), all date formats and their failure modes, every code of the three payment referentials, the signature structure, the amount checks, translation completeness in all three languages, Laravel wiring, and Java parity across all 55 fixtures.
The suite also enforces the traceability chain structurally: every error code must appear in the rule catalogue with a specification source, must be emitted by some rule, and must be exercised by a test. A rule with no test fails the build.
composer build:schemas # regenerate the XSD 1.0 schemas from TTN's originals composer build:matrix # regenerate docs/TRACEABILITY.md
To re-derive the parity manifest (needs a JDK and the Xerces JARs — see
tools/java-oracle/README.md):
php tools/capture-java-parity.php --xsd-dir=/path/to/ttn/xsd
Versioning
Semantic Versioning. Error codes are part of the public API and will not change or be removed within a major version. Error messages are for humans and may be reworded in a minor release — do not match on them.
The TEIF schema revision (currently 1.8.8) is independent of the package version: a new revision ships as a minor release when it is additive, and a major one when it changes existing verdicts.
Compatibility
| Requirement | Version |
|---|---|
| PHP | 8.2, 8.3, 8.4 |
| Extensions | dom, libxml, mbstring (all standard) |
| Laravel | 12 and 13 — optional (Laravel 13 requires PHP 8.3+) |
| TEIF schema | 1.8.8, in either XSD 1.1 or XSD 1.0 form |
| Signature | XAdES-B, Signature Fournisseur V3.0 |
Laravel 10 and 11 are not supported. Every published version of
laravel/framework 10.x and 11.x carries unfixed security advisories, so
Composer refuses to install them under its default policy — the constraint could
only ever fail to resolve. Both branches are past their security-fix window.
Laravel is optional. Without it, use XmlValidator::make(); the service provider
and facade are the only Laravel-coupled classes, and a CI job runs the suite with
no framework installed to keep it that way.
Security
Input is parsed without LIBXML_NOENT and with LIBXML_NONET, so external
entities are never substituted and the parser never reaches the network. This
closes off XXE and billion-laughs attacks on untrusted invoices; there is a
regression test for it.
The signed schema's xmldsig import, which upstream points at a W3C URL, is
repointed at a vendored copy for the same reason.
This package validates invoice structure. It does not verify signatures cryptographically — see Signature validation.
Sources
Everything implemented here traces to a document published by Tunisie TradeNet:
| Document | Version | Used for |
|---|---|---|
| Guide d'Implémentation du message TEIF | 2.0, 20/10/2021 | element structure, §5.2.1.1 matricule fiscal, §5.4.4 dates, §5.6 payment, Annexe A referentials |
facture_INVOIC_V1.8.8_withoutSig.xsd / withSig.xsd |
1.8.8 | the schema layer |
| Spécifications Techniques de la Signature Fournisseur | 3.0, 29/05/2026 | XAdES-B structure, UTF-8, one signature per submission |
| Spec FTP | 2.0 | file naming (W_DOC_001) |
exemple_signe_elfatoora.xml |
— | fixtures, amount conventions |
License
MIT. See LICENSE.