If you write invoicing software in PHP, you probably already produce Peppol UBL. num-num/ubl-invoice and josemmo/einvoicing do that well, and they are widely used.
Then comes the part they do not cover: getting the XML to your customer's customer. On Peppol that means going through an access point, and in PHP there are not many packages for that step.
So we published one. getpeppr/getpeppr-php takes the UBL you already have and sends it through the getpeppr API. It does not generate, rewrite or repair the document. Version 0.1.0 is MIT, needs PHP 8.2 or later (tested up to 8.5), and uses the PSR-18 HTTP client your application already has.
composer require getpeppr/getpeppr-php From num-num/ubl-invoice to the network
use Getpeppr\Client; use NumNum\UBL; $invoice = (new UBL\Invoice()) ->setCustomizationId('urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0') ->setProfileId('urn:fdc:peppol.eu:2017:poacc:billing:01:1.0') ->setId('INV-2026-001') // … supplier, customer, lines and totals, as you already do ; $xml = (new UBL\Generator())->invoice($invoice, 'EUR'); $getpeppr = new Client((string) getenv('GETPEPPR_API_KEY')); $operationId = bin2hex(random_bytes(16)); // created once, stored with the invoice $sent = $getpeppr->importInvoice( $xml, 'INV-2026-001.xml', '9925:BE0314595348', // the recipient's Peppol id idempotencyKey: $operationId, ); echo $sent->providerDocumentId; With josemmo/einvoicing it is the same call, you just export with UblWriter:
$xml = (new Einvoicing\Writers\UblWriter())->export($invoice); $sent = $getpeppr->importInvoice($xml, 'F-2026-001.xml', '9925:BE0314595348', idempotencyKey: $operationId); The third argument decides where the document goes, nothing else. If it disagrees with the customer address inside the XML, the send still goes through and $sent->warnings tells you.
A successful call means the document was accepted for transmission, not that it was delivered. You read the status with getInvoice(), or you listen to the webhooks.
The part I care most about: what to do when it fails
Peppol sending fails for two very different reasons, and both can answer "not retryable":
- the document is wrong (a Peppol business rule is broken): resending the same XML will fail again, you have to fix it;
- the account cannot send yet (identity not verified, platform contract not active): the document is fine, you must keep it and send it later.
If your code treats both the same, you either lose a valid invoice or you loop forever on a broken one. The package separates them with the exception class, based on the Getpeppr-Remediation header of the API, not on retryable alone:
use Getpeppr\Exception; try { $sent = $getpeppr->importInvoice($xml, $filename, $recipient, idempotencyKey: $operationId); } catch (Exception\DuplicateDocumentException $e) { markAsSent($invoice, $e->duplicateOf); // it already left } catch (Exception\TransportException | Exception\RetryableException $e) { retryLater($invoice, $operationId, $e->retryAfter); // same key } catch (Exception\ContactSupportException $e) { holdUntilAccountReady($invoice, $e->resultCode); // keep the document } catch (Exception\InvalidRequestException $e) { markAsRejected($invoice, $e->resultCode, $e->resultDocs); // fix it, send it under a new key } Two details that matter in production:
-
TransportExceptionmeans no answer arrived. The document may have been sent. Resend the same XML with the same idempotency key: you get the201of the document if it went out, never a second copy. While the first attempt is still running, or if the resend meets a refusal that sends nothing (a rate limit, for example), you get an exception instead: try again later, same key. - A corrected invoice is a new send, so it gets a new key. Once a key was used to send something, it stays tied to it, even if the network refused the document. That is also why the key should not be built from the invoice number: use an opaque id per send, stored before the call.
The package never retries on its own and writes no logs. Your API key, the XML and the invoice number never appear in an exception message.
Sending for your customers
If you are a platform, a master key lets you create each customer once and send in its name. The supplier written in the XML must be that customer: getpeppr never rewrites your document, so a document naming another supplier is refused.
use Getpeppr\Sender; $getpeppr->importInvoice($xml, 'INV-8412-001.xml', '9925:BE0314595348', sender: Sender::externalSubTenant('customer_8412'), idempotencyKey: $operationId, // created once, stored with the invoice ); createLegalEntity(), listLegalEntities() and getLegalEntity() manage those customers.
Webhooks
Webhook::verify() checks the signature in constant time and refuses a timestamp more than 300 seconds away from now. It cannot tell you an event is new: deliveries are at least once, so the README shows how to reserve the event id in the same transaction as your effect.
What it is not
It is not a port of our TypeScript SDK and it does not generate UBL. Version 0.x, so the interface can still move. The countries you can send from depend on the getpeppr service, they are listed on getpeppr.dev/countries.
Try it
The sandbox is free and does not expire. Documents travel on the Peppol test network, to test participants like 9925:BE0314595348 used above.
- Sandbox key: console.getpeppr.dev
- Docs: getpeppr.dev/docs/php
- Code, examples and issues: github.com/zerolooplabs/getpeppr-php
If something does not work as written here, please open an issue on the repo.