vimatech / laravel-einvoicing
Generate compliant structured e-invoices (Peppol BIS 3.0 UBL, EN 16931 CII) natively and dispatch them through pluggable networks. Zero third-party runtime dependencies.
Requires
- php: ^8.3
- ext-dom: *
- ext-mbstring: *
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- illuminate/http: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-04 19:26:30 UTC
README
Generate compliant structured e-invoices natively and dispatch them through pluggable networks (Peppol access points, French PDPs), with per-country routing — for Laravel 11, 12 and 13.
Zero third-party runtime dependencies. Every document is built with PHP's own
ext-dom; every network call uses Laravel's own HTTP client. Nohorstoeko/zugferd, no UBL libraries, no PDF libraries. This is a deliberate security & maintenance policy.
Features
- Native generators — Peppol BIS Billing 3.0 (UBL invoice + credit note) and EN 16931 CII,
emitted directly with
DOMDocument. Factur-X (PDF/A-3) is stubbed for a later isolated module. - Neutral domain model — a single
CanonicalInvoiceDTO; no format or vendor concept ever leaks into your application. - Pluggable networks —
PeppolDriver,FrPdpDriver,NullDriver,FakeDriver, plus your own. - Per-country routing — map destination countries to networks, with a fallback and a per-tenant override hook.
- Profile-scoped validation — mandatory-field and arithmetic checks fail fast with actionable messages before anything is rendered or transmitted. The EN 16931 core applies to every document; the extra rules of a CIUS apply only when that profile is the one being emitted. Arithmetic checks allow a fixed 0.02 tolerance to absorb per-line rounding.
- Lifecycle events —
EInvoiceGenerated,EInvoiceDispatched,EInvoiceDelivered,EInvoiceRejected,EInvoiceReceived.
Requirements
- PHP 8.3+
- Laravel 11, 12 or 13
- Extensions:
ext-dom,ext-mbstring
Installation
composer require vimatech/laravel-einvoicing
Publish the config (optional):
php artisan vendor:publish --tag=einvoicing-config
Quick start
1. Build a canonical invoice
The CanonicalInvoice is the only model you ever construct. It is format- and network-agnostic.
use Vimatech\EInvoicing\Dtos\{CanonicalInvoice, Party, LineItem, TaxBreakdown}; $invoice = new CanonicalInvoice( number: 'INV-2024-0001', issueDate: new DateTimeImmutable('2024-01-15'), currency: 'EUR', seller: new Party( name: 'Acme Trading Ltd.', countryCode: 'BE', endpointId: '0123456789', // Peppol electronic address (BT-34), bare endpointScheme: '0208', // EAS scheme id, emitted as schemeID vatId: 'BE0123456789', legalRegistrationId: '0123456789', legalRegistrationScheme: '0208', street: 'Main street 1', city: 'Brussels', postalZone: '1050', ), buyer: new Party( name: 'Globex NV', countryCode: 'BE', endpointId: '9876543210', endpointScheme: '0208', vatId: 'BE9876543210', street: 'Market square 9', city: 'Antwerp', postalZone: '2000', ), lines: [ new LineItem( id: '1', name: 'Laptop computer', quantity: 5.0, netPrice: 200.0, lineExtensionAmount: 1000.0, taxCategory: 'S', taxPercent: 21.0, ), ], taxBreakdowns: [ new TaxBreakdown(category: 'S', percent: 21.0, taxableAmount: 1000.0, taxAmount: 210.0), ], dueDate: new DateTimeImmutable('2024-02-14'), buyerReference: 'PO-98765', );
Document totals (line extension, tax exclusive/inclusive, payable) are derived from the lines and the VAT breakdown — you do not pass them in.
2. Generate a UBL (Peppol BIS 3.0) document
use Vimatech\EInvoicing\Facades\EInvoice; use Vimatech\EInvoicing\Enums\Format; $document = EInvoice::format(Format::Ubl)->generate($invoice); $document->contents; // the XML string $document->mimeType; // application/xml $document->profile; // urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 $document->save('/path/to/INV-2024-0001.xml');
A credit note is the same call with typeCode: CanonicalInvoice::TYPE_CREDIT_NOTE — the
generator switches to the CreditNote root and CreditedQuantity automatically. Generate CII
with Format::Cii.
Name the invoice a credit note corrects with precedingInvoiceReference (EN 16931 group BG-3):
use Vimatech\EInvoicing\Dtos\PrecedingInvoiceReference; $creditNote = new CanonicalInvoice( // ... typeCode: CanonicalInvoice::TYPE_CREDIT_NOTE, precedingInvoiceReference: new PrecedingInvoiceReference( number: 'INV-2024-0001', // BT-25 issueDate: new DateTimeImmutable('2024-01-15'), // BT-26, optional ), );
It becomes cac:BillingReference/cac:InvoiceDocumentReference in UBL and
ram:InvoiceReferencedDocument in CII. EN 16931 leaves the group optional, so the package does not
require it — but a credit note that names no preceding invoice is rejected by the profiles that do,
including the French PDP rules. The same field is valid on a 380 invoice, where it points at the
partial or pre-payment invoices a final invoice completes.
If a mandatory field is missing or the arithmetic does not balance, generation throws
InvalidInvoice, which carries the full list of violations:
use Vimatech\EInvoicing\Exceptions\InvalidInvoice; try { EInvoice::format(Format::Ubl)->generate($invoice); } catch (InvalidInvoice $e) { $e->violations(); // ['BT-1: invoice number is required', ...] }
Which rules run depends on what is being emitted. Format::Cii carries the plain EN 16931 guideline
identifier and is validated against the core alone; Format::Ubl carries the Peppol BIS Billing 3.0
customization identifier and additionally enforces the Peppol rules — electronic addresses for both
parties, and PEPPOL-EN16931-R003, which requires a buyer reference (BT-10) or a purchase order
reference (BT-13). Both terms are optional in the core standard, so neither is required of a CII
document.
Validate against a profile without generating anything:
use Vimatech\EInvoicing\Enums\ValidationProfile; use Vimatech\EInvoicing\Formats\Support\InvoiceValidator; InvoiceValidator::assertConformsTo($invoice, ValidationProfile::PeppolBis); (new InvoiceValidator)->violations($invoice); // EN 16931 core, as a list
Reach for ValidationProfile::PeppolBis on a CII document when you transmit it through Peppol, or
to a CIUS that carries the same requirement — XRechnung enforces the buyer reference through
BR-DE-15, and Chorus Pro requires a service code or an engagement number for the public entities
whose directory entry demands one.
3. Send through a network
send() renders the document, routes it by the buyer's country, transmits it, and fires the
lifecycle events:
$result = EInvoice::send($invoice); // Format defaults to config('einvoicing.default_format') $result->status; // LifecycleStatus::Delivered | Submitted | Rejected | ... $result->messageId; // poll later with fetchStatus() $result->successful(); // bool
Force a format or a specific network:
EInvoice::send($invoice, Format::Ubl, networkKey: 'peppol');
Resolve a network yourself:
EInvoice::route('BE'); // network responsible for Belgium EInvoice::network('peppol'); // a network by key // messageId is null when the network returned none (NullDriver, a partner that // does not acknowledge with an id), so guard before polling. if ($result->messageId !== null) { $status = EInvoice::network('peppol')->fetchStatus($result->messageId); }
send() always fires EInvoiceDispatched. EInvoiceDelivered fires only for Delivered and
Accepted, EInvoiceRejected only for Rejected and Failed. A queued submission, a document
still in transit, or a status your status_map does not cover fires neither — poll fetchStatus()
rather than treating the absence of a delivery as a refusal.
4. Receive inbound documents
foreach (EInvoice::receive('peppol') as $inbound) { $inbound->contents; // decoded document body $inbound->senderId; // sender electronic address } // each inbound document also fires an EInvoiceReceived event
Bodies arrive base64-encoded. A body that is absent, that is not valid base64, or that decodes to
nothing raises NetworkException naming the offending message id, and the whole batch stops — the
package will not hand your application a document it could not decode. If your partner returns
unencoded bodies, extend the driver and override decodeInbound(); do not make it accept both,
since the two cannot be told apart and the wrong guess silently yields a corrupt invoice.
Configuration
config/einvoicing.php declares the available networks, the country → network routing
table, and the default format. Credentials come from the environment.
HTTP drivers read base_url, token, auth, timeout, headers, verify, paths and
status_map. A setting that is present but cannot be read as the type it needs raises
InvalidDriverConfig rather than being ignored, and environment strings such as "120" and
"false" are understood — a value you set is either applied or reported, never dropped.
'networks' => [ 'peppol' => [ 'driver' => 'peppol', 'base_url' => env('PEPPOL_BASE_URL'), 'token' => env('PEPPOL_API_TOKEN'), 'paths' => ['send' => '/documents', 'status' => '/documents/{id}/status', 'inbound' => '/inbound'], 'status_map' => [], // map partner status strings -> LifecycleStatus values ], 'fr_pdp' => ['driver' => 'fr_pdp', 'base_url' => env('FR_PDP_BASE_URL'), 'token' => env('FR_PDP_API_TOKEN')], 'sandbox' => ['driver' => 'null'], ], 'routing' => [ 'FR' => 'fr_pdp', 'BE' => 'peppol', 'NL' => 'peppol', // ... ], 'fallback' => env('EINVOICING_FALLBACK_NETWORK'), // null => unmatched countries throw
Adapting a built-in driver to your partner
PeppolDriver and FrPdpDriver speak a small, neutral REST shape. Point them at your access
point / PDP by overriding paths and, where the vocabulary differs, status_map — no code change:
'peppol' => [ 'driver' => 'peppol', 'base_url' => env('PEPPOL_BASE_URL'), 'token' => env('PEPPOL_API_TOKEN'), 'timeout' => env('PEPPOL_TIMEOUT', 30), 'paths' => [ 'send' => '/v2/outbound', 'status' => '/v2/messages/{id}', 'inbound' => '/v2/inbound', ], 'status_map' => [ 'in_progress' => 'in_transit', 'done' => 'delivered', ], ],
A missing or empty token is refused: an unauthenticated request to an accredited platform is
rejected without a reason you can act on. When your partner authenticates another way — mutual
TLS, a signed header — declare it, and configure no token:
'peppol' => [ 'driver' => 'peppol', 'base_url' => env('PEPPOL_BASE_URL'), 'auth' => 'none', 'headers' => ['X-Api-Signature' => env('PEPPOL_SIGNATURE')], ],
Adding a country
Add a row to the routing map pointing at any configured network key:
'routing' => [ 'ES' => 'peppol', 'IT' => 'peppol', ],
Unmatched countries throw UnsupportedCountry unless a fallback network is set.
Per-tenant routing override
Register a resolver in a service provider to override routing per tenant or per invoice.
Returning a network key wins over the static map; returning null defers to it. The router is a
singleton, so the resolver lives for the whole process: under Octane, registering it inside a
request leaks it to every later request on that worker. Resolve the tenant inside the closure, as
below, rather than closing over one:
use Vimatech\EInvoicing\Facades\EInvoice; EInvoice::router()->overrideUsing(function (string $country, ?CanonicalInvoice $invoice) { return tenant()->prefersDirectPeppol() ? 'peppol' : null; });
Adding a driver
Implement EInvoiceNetwork (or extend AbstractHttpDriver for a REST partner) — keep every vendor
concept inside the driver:
namespace App\EInvoicing; use Vimatech\EInvoicing\Contracts\EInvoiceNetwork; use Vimatech\EInvoicing\Dtos\{CanonicalInvoice, DispatchResult, GeneratedDocument, NetworkCapabilities}; use Vimatech\EInvoicing\Enums\{Format, LifecycleStatus}; final class AcmeDriver implements EInvoiceNetwork { public function __construct(private array $config, private string $key) {} public function key(): string { return $this->key; } public function send(GeneratedDocument $document, CanonicalInvoice $invoice): DispatchResult { // ... call your partner, then normalise the response ... return new DispatchResult(LifecycleStatus::Submitted, $this->key, messageId: '...'); } public function fetchStatus(string $messageId): DispatchResult { /* ... */ } public function receive(): array { return []; } public function capabilities(): NetworkCapabilities { return new NetworkCapabilities($this->key, formats: [Format::Ubl]); } }
Reference it by class in config (it is resolved from the container):
'networks' => [ 'acme' => ['driver' => App\EInvoicing\AcmeDriver::class, /* ... */], ],
Or register a factory for a driver alias at runtime. extend() is keyed by the driver name,
so the network entry must name that alias:
'networks' => [ 'acme' => ['driver' => 'acme', 'base_url' => env('ACME_BASE_URL')], ],
app(\Vimatech\EInvoicing\Networks\NetworkManager::class) ->extend('acme', fn (array $config, string $key) => new App\EInvoicing\AcmeDriver($config, $key));
Testing with the FakeDriver
A dependency-free FakeDriver is shipped for your test suite. Swap any network for it and
assert against what was sent — no HTTP, no credentials:
use Vimatech\EInvoicing\Facades\EInvoice; use Vimatech\EInvoicing\Enums\LifecycleStatus; it('sends the invoice to Peppol', function () { $fake = EInvoice::fake('peppol'); EInvoice::send($invoice); $fake->assertSent(fn ($invoice) => $invoice->number === 'INV-2024-0001'); $fake->assertSentCount(1); expect($fake->lastSent()->number)->toBe('INV-2024-0001'); }); it('handles a rejection', function () { EInvoice::fake('peppol')->alwaysReturn(LifecycleStatus::Rejected); expect(EInvoice::send($invoice)->rejected())->toBeTrue(); });
FakeDriver also supports pushInbound() for receive() flows, the inspection API
(sent(), sentCount(), hasSent(), lastSent()) and the assert* helpers.
Conformance & testing notes
- Profiles emitted: Peppol BIS Billing 3.0 —
CustomizationIDurn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0,ProfileIDurn:fdc:peppol.eu:2017:poacc:billing:01:1.0. CII carries the EN 16931 guidelineurn:cen.eu:en16931:2017. - Scope: only the subset of business terms required for the supported fields is emitted, in the UBL 2.1 / CII sequence order. Document-level allowances/charges and multi-currency VAT accounting are intentionally out of scope for this release.
- Golden files: UBL output is asserted byte-for-byte against committed golden samples
(
tests/fixtures/peppol/) shaped after the official Peppol BIS 3.0 examples, plus structural XPath assertions for identifiers and balancing totals. - Recommended external validation: before production, run generated XML through the official Peppol BIS / EN 16931 validator or the CEN Schematron. Native validation here is a fast pre-flight, not a substitute for the Schematron.
Run the suite:
composer test # Pest + orchestra/testbench composer analyse # PHPStan level max composer format # Laravel Pint
Architecture
CanonicalInvoice ──► FormatGenerator ──► GeneratedDocument
│ (Ubl / Cii / FacturX)
│
└──► EInvoiceRouter ──► EInvoiceNetwork ──► DispatchResult
(by country) (Peppol / FrPdp / Null / Fake / yours)
Dtos/— readonly value objects (the neutral model).Formats/— nativeDOMDocumentgenerators + validation.Networks/— drivers + the config-drivenNetworkManager.Routing/—EInvoiceRouter(country map + fallback + override hook).Events/,Exceptions/,Facades/— the glue.
Contributing
Contributions are welcome.
Please ensure:
- Tests pass (
composer test) - PHPStan passes (
composer analyse) - Code style is formatted with Pint (
composer format)
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our Security Policy for reporting vulnerabilities.
License
The MIT License (MIT). Please see License File for more information.
Credits
Built and maintained by Vimatech. Created by Adel Zemzemi.