einvoicing / laravel
Peppol e-invoicing for Laravel: validate, convert and look up documents with the einvoicing.dev API.
Requires
- php: ^8.4
- einvoicing/sdk: ^0.2
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.9
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
- phpstan/phpstan: ^2.0
Suggests
- guzzlehttp/guzzle: Any PSR-18 client and PSR-17 factories will do; this is the one most Laravel apps already have.
Provides
None
Conflicts
None
Replaces
None
README
Peppol e-invoicing for Laravel: validate, convert and look up documents with the einvoicing.dev API.
It wraps einvoicing/sdk in the things a Laravel application already expects — a config file, a facade, a validation rule, Artisan commands, cached lookups and a testing fake — so nothing here needs a wrapper of your own.
Install
Requires PHP 8.4 and Laravel 13.
composer require einvoicing/laravel php artisan vendor:publish --tag=einvoicing-config
EINVOICING_KEY=sk_test_... EINVOICING_RULESET=peppol-bis-billing-3.0.21
Pin the ruleset. Leave it out and validation follows whatever release is current, which means a change elsewhere can turn a passing build red without anything of yours changing.
Validating
use Einvoicing\Laravel\Facades\Einvoicing; $report = Einvoicing::validate($xml); if (! $report->valid) { foreach ($report->errors() as $finding) { logger()->warning("{$finding->ruleId}: {$finding->message}", [ 'fix' => $finding->fix, 'terms' => $finding->businessTerms, ]); } }
An invalid document is a successful call. The report carries every finding, not only the first — the second one is usually the interesting one.
As a validation rule
use Einvoicing\Laravel\Rules\PeppolDocument; $request->validate([ 'invoice' => ['required', 'string', new PeppolDocument], ]);
Each error becomes its own message, naming the rule and quoting the fix.
Warnings are ignored unless you ask for them: new PeppolDocument(warningsFail: true).
Converting your own data
$conversion = Einvoicing::convert([ 'number' => $invoice->number, 'issued' => $invoice->issued_at->toDateString(), 'currency' => $invoice->currency, // seller, buyer, lines, payment... ]); Storage::put("invoices/{$invoice->id}.xml", $conversion->document);
Totals and the VAT breakdown are worked out from the lines, and the result is
validated before it is returned. An invoice that cannot produce a valid
document throws InvalidInvoiceException, whose findings() say why.
Looking a participant up
if (! Einvoicing::canReceive($customer->peppol_id)) { // Tell them, rather than failing a send later. } $participant = Einvoicing::participant($customer->peppol_id); $participant->registered; $participant->capabilities;
Lookups go through Laravel's cache — five minutes by default, matching the
API's own caching. A participant who moves Access Point keeps their
identifier, so re-check with Einvoicing::participant($id, fresh: true) when
a send fails.
Two traps the SDK handles for you. A business absent from the optional Peppol
Directory may still be registered and reachable, so directory being null
means nothing on its own. And a UK VAT number is registered with or without
its GB prefix, as two different participants; the lookup tries both and
reports the form that answered. Store that form.
From the command line
php artisan einvoicing:validate storage/app/invoice.xml php artisan einvoicing:validate storage/app/invoice.xml --strict php artisan einvoicing:participant 9932:GB123456789 php artisan einvoicing:usage
einvoicing:validate exits non-zero on an invalid document, so it drops into
CI as it stands.
Testing
use Einvoicing\Laravel\Facades\Einvoicing; it('will not send an invoice the network would reject', function () { $fake = Einvoicing::fake()->shouldBeInvalid(['PEPPOL-EN16931-R003']); $this->post('/invoices/1/send')->assertSessionHasErrors(); $fake->assertValidated(); });
Nothing leaves the machine once the fake is installed. Documents come back valid and participants come back registered unless you say otherwise, because the interesting tests are the ones where you say otherwise:
Einvoicing::fake()->shouldBeInvalid(['BR-CO-10']); // by rule id Einvoicing::fake()->shouldBeInvalid([[...]]); // full findings Einvoicing::fake()->shouldFind('9932:GB1', registered: false); // off the network Einvoicing::fake()->shouldFind('9932:GB1', documentTypes: ['Order-2::Order']); Einvoicing::fake()->shouldFindNobody();
And the assertions:
$fake->assertValidated(); $fake->assertValidated(fn (string $xml) => str_contains($xml, 'INV-1')); $fake->assertValidatedCount(1); $fake->assertNothingValidated(); $fake->assertConverted(fn (array $invoice) => $invoice['number'] === 'INV-1'); $fake->assertLookedUp('9932:GB123456789'); $fake->assertNothingLookedUp();
The HTTP client
This package names no HTTP client. The SDK discovers the PSR-18 client and PSR-17 factories from whatever your application has installed, so there is nothing to configure.
Most Laravel apps already have Guzzle, since that is what the Http facade
uses, and that is all discovery needs.
If yours does not, allow the discovery plugin and composer installs a pair for you — it asks on install, and saying yes is enough:
{
"config": {
"allow-plugins": { "php-http/discovery": true }
}
}
or install one yourself:
composer require guzzlehttp/guzzle
Decline the plugin and install nothing, and the install still succeeds —
nothing checks at that point — but the first request throws
NoHttpClientException.
To supply your own — an instrumented client, a proxy, a different timeout — replace the whole client rather than the pieces inside it:
$this->app->singleton(Einvoicing\Client::class, fn () => new Einvoicing\Client( key: config('einvoicing.key'), http: new MyClient, ));
One override point, and it is the object the facade actually uses. Binding
ClientInterface on its own does nothing here — discovery is the mechanism,
not the container.
Errors
Every failure is an Einvoicing\Exceptions\EinvoicingException:
UnauthenticatedException, AllowanceExhaustedException,
RateLimitedException (with retryAfter()), NotFoundException,
InvalidInvoiceException (with findings()), ProblemException and
TransportException. Branch on $e->type or $e->slug(), never on the
title or the detail — those are prose for a human reading a log.
Development
composer test # Pest, against Testbench composer stan # PHPStan, level 10 composer lint # Pint
Licence
MIT.