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.

Maintainers

Package info

github.com/ayoubgaouet/tn-einvoice-validator

pkg:composer/ayoubgaouet/tn-einvoice-validator

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.0 2026-08-29 22:42 UTC

This package is auto-updated.

Last update: 2026-08-29 22:50:58 UTC


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

  1. Why
  2. Installation
  3. Configuration
  4. Basic usage
  5. Validating a string, a file, a document
  6. Validation result
  7. Errors and warnings
  8. Error codes
  9. Languages
  10. Reference data
  11. What gets validated
  12. Rules the XSD does not enforce
  13. Where the Guide and the XSD disagree
  14. Inferred rules
  15. Signature validation
  16. Parity with the Java validator
  17. Laravel integration
  18. Custom rules
  19. Testing
  20. Versioning
  21. Compatibility
  22. Security
  23. 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, 2 xs: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:

  1. Verdicts agree unless the divergence is declared with a type and a justification (spec_rule or schema_gap).
  2. 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.