Introducing the eCourier PHP SDK and Laravel package

Albert Haff

If you are building on PHP or Laravel, integrating with the eCourier API just got a lot quicker. We have published two open-source packages: a framework-agnostic PHP SDK, and a Laravel wrapper on top of it.

Both are on GitHub and Packagist today:

Why an SDK

The eCourier API is a plain REST API, so you could always reach for curl or Guzzle directly. But invoices and credit notes carry a lot of structure โ€” parties, lines, totals, tax breakdowns โ€” and hand-rolling that as arrays gets error-prone fast. Typos in array keys, missing required fields, no autocomplete.

The SDK wraps the full API surface in typed data objects instead, so your editor tells you what a document needs before the API does.

Installing the PHP SDK

composer require ecourier/ecourier

Requires PHP 8.3+. Instantiate the connector with an API key โ€” the prefix (pk_test_ or pk_live_) determines the mode:

use Ecourier\EcourierConnector;

$ecourier = new EcourierConnector(apiKey: 'pk_test_your_key_here');

Every request is authenticated automatically. No headers to wire up yourself.

Sending an invoice

Build the invoice as a typed InvoiceDocumentData object, and eCourier converts it to the correct UBL/XML schema for you:

use Ecourier\Data\Invoice\InvoiceDocumentData;
use Ecourier\Data\Invoice\InvoiceLineData;
use Ecourier\Data\Invoice\InvoicePartyData;
use Ecourier\Data\Invoice\InvoiceTotalsData;
use Ecourier\Data\Invoice\ParticipantIdentifier;
use Ecourier\Enums\Channel;
use Ecourier\Enums\Currency;
use Ecourier\Enums\DocumentType;
use Ecourier\Enums\IdentifierScheme;

$invoice = new InvoiceDocumentData(
    type: DocumentType::Invoice,
    id: 'INV-2024-001',
    issueDate: '2024-06-01',
    currency: Currency::DKK,
    supplier: new InvoicePartyData(
        participant: new ParticipantIdentifier(IdentifierScheme::DK_CVR, '12345678'),
    ),
    customer: new InvoicePartyData(
        participant: new ParticipantIdentifier(IdentifierScheme::DK_CVR, '87654321'),
    ),
    lines: [new InvoiceLineData(id: 1)],
    totals: new InvoiceTotalsData(
        subtotalAmount: '1000.00',
        taxAmount: '250.00',
        totalAmount: '1250.00',
    ),
);

$document = $ecourier->documents()->sendJson(Channel::Peppol, $invoice);

echo $document->id; // 01kmkdaf55vrrecfy70180tpr6

If you already generate UBL XML elsewhere in your stack, you don't need to give that up โ€” sendXml() takes raw XML alongside the routing headers, so the SDK still handles delivery and status tracking for you.

Everything else in the API follows the same pattern. Companies, participants, and network lookups are each their own resource on the connector, and list endpoints return a lazy paginator so you can iterate through results โ€” or collect() them into a single collection โ€” without thinking about page numbers.

Adding the Laravel package

If you're on Laravel, ecourier/ecourier-laravel saves you the boilerplate of binding the connector yourself and gives you config and webhook handling out of the box:

composer require ecourier/ecourier-laravel
php artisan vendor:publish --tag=ecourier-config
ECOURIER_API_KEY=pk_test_your_key

The connector is resolved from the container, so you can pull it in wherever you need it:

use Ecourier\EcourierConnector;

$document = app(EcourierConnector::class)->documents()->find('doc_01xyz');

Handling webhooks

Incoming webhooks are registered at /webhooks/ecourier by default, built on top of spatie/laravel-webhook-client. Set a webhook secret and listen for the parsed event:

ECOURIER_WEBHOOK_SECRET=your_webhook_secret
use Ecourier\Laravel\Events\EcourierWebhookReceived;

Event::listen(EcourierWebhookReceived::class, function (EcourierWebhookReceived $event) {
    $event->webhook; // Ecourier\Data\Webhook\DocumentWebhook โ€” parsed and typed
});

No manual signature verification, no raw payload parsing โ€” by the time your listener runs, you're working with a typed object.

Try it out

Both packages are MIT-licensed and open to contributions. Star them, open issues, or send a PR:

If you run into anything or want to see a resource covered that isn't yet, we would love to hear about it.