einvoicing / sdk
PHP client for the einvoicing.dev API: validate, convert and look up Peppol e-invoices.
Requires
- php: ^8.4
- composer-runtime-api: ^2.0
- php-http/discovery: ^1.20
- psr/http-client: ^1.0
- psr/http-client-implementation: ^1.0
- psr/http-factory: ^1.0
- psr/http-factory-implementation: ^1.0
- psr/http-message: ^2.0
Requires (Dev)
- laravel/pint: ^1.18
- nyholm/psr7: ^1.8
- pestphp/pest: ^5.0
- phpstan/phpstan: ^2.0
- symfony/http-client: ^7.1|^8.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
PHP client for the einvoicing.dev API: validate, convert and look up Peppol e-invoices.
It brings no HTTP client of its own. It discovers the PSR-18 client and PSR-17 factories you already have, so it uses whatever is in your application and adds nothing to your dependency tree that you have not already agreed to.
Using Laravel? einvoicing/laravel wraps this package in a service provider, a config file and a testing fake.
Install
Requires PHP 8.4 or newer.
composer require einvoicing/sdk
You also need a PSR-18 client and PSR-17 factories. Allow the discovery plugin and composer installs a pair for you — composer asks about this on install, and saying yes is enough:
{
"config": {
"allow-plugins": { "php-http/discovery": true }
}
}
Already have an implementation, or want to pick your own? Install it and the plugin leaves it alone:
composer require symfony/http-client nyholm/psr7
Decline the plugin and install nothing, and the install still succeeds —
nothing checks at that point — but the first request throws
NoHttpClientException naming what to install.
Getting started
use Einvoicing\Client; $client = new Client(getenv('EINVOICING_API_KEY'));
That is the whole setup. The HTTP client and factories are discovered from
what is installed, via php-http/discovery.
Pass any of them to take over — a framework's container should, and it is how you put a fake under a test:
$client = new Client( key: getenv('EINVOICING_API_KEY'), http: $myClient, // any PSR-18 client requests: $myFactory, // any PSR-17 request factory streams: $myFactory, // any PSR-17 stream factory baseUrl: 'http://localhost:8787', );
Give one and the rest is still discovered. If nothing can be found and nothing
was passed, the constructor throws NoHttpClientException naming what to
install.
Validating a document
$report = $client->validations()->validate($xml); if (! $report->valid) { foreach ($report->errors() as $finding) { echo "{$finding->ruleId}: {$finding->message}\n"; echo " {$finding->explanation}\n"; echo " {$finding->fix}\n"; } }
An invalid document is a successful request. It comes back as a report
whose valid is false, with every finding on it — not as an exception
carrying only the first. The second finding is usually the interesting one.
Each finding carries the official rule text in message, this API's plain
English in explanation, and the layer it came from. A schema error and a
Peppol rule error are different kinds of problem, and the layer says which
you have.
Pin the ruleset in your own configuration rather than letting it float:
$report = $client->validations()->validate($xml, 'peppol-bis-billing-3.0.21');
Otherwise a release elsewhere can turn your passing build red without anything of yours changing.
Converting your own data
$conversion = $client->conversions()->convert([ 'number' => 'INV-2026-0042', 'issued' => '2026-09-18', 'currency' => 'GBP', // seller, buyer, lines, payment... ]); $xml = $conversion->document;
Totals and the VAT breakdown are worked out from the lines, and the result is
validated before it is returned, so a conversion never hands back an invalid
document. An invoice that cannot produce one throws
InvalidInvoiceException, whose findings() say why.
Looking a participant up
$participant = $client->participants()->find('9932:GB123456789'); if (! $participant->registered) { // Not on the network. Not an error — a fact about the world. } if (! $participant->accepts('Invoice-2::Invoice')) { // Registered, but not for invoices. A different problem, different fix. }
Two traps this handles for you. A business absent from the optional Peppol
Directory (directory is null) may still be registered and perfectly
reachable — only the SML and SMP are authoritative. 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,
not the one you sent.
Account and keys
$usage = $client->usage()->get(); $usage->documents->remaining(); // derived; the API sends used, included, overage $key = $client->keys()->create('CI', mode: 'test'); $key->secret; // The only time this exists. Store it now. $client->keys()->revoke($key->id); $client->rulesets()->current(); $client->account()->get();
test keys are free, unmetered, and cannot touch the account — which makes
them the right thing to put in CI.
Errors
Every failure is an Einvoicing\Exceptions\EinvoicingException. The API
answers with RFC 9457 problem documents, and the common ones have their own
class:
| Exception | When |
|---|---|
UnauthenticatedException |
The key is missing, wrong or revoked |
AllowanceExhaustedException |
The period's allowance is used up |
RateLimitedException |
Too many requests; retryAfter() says how long |
NotFoundException |
No such resource |
InvalidInvoiceException |
A conversion could not produce a valid document; findings() say why |
ProblemException |
Anything else the API reported |
TransportException |
The request never got an answer |
NoHttpClientException |
No PSR-18 client or PSR-17 factory could be found |
Branch on $e->type or $e->slug(), which are stable. Never on the title or
the detail: those are prose for a human reading a log, and they change.
Testing
Pass a PSR-18 client that answers from a fixture; the factories can still be discovered:
$client = new Client('sk_test', http: $fakeClient);
There is nothing else to mock — the client holds no global state.
Development
composer test # Pest composer stan # PHPStan, level 10 composer lint # Pint
Licence
MIT.