Skip to content
einvoicing.dev

The guideChapter 07

Building it: Laravel and Go

The three parts of an integration: a writer, a validator in CI, a lookup, and the schema changes that have to come first.

Six chapters of standard. This one is the code, and the shape of it is smaller than the reading suggests: a writer that turns your data into a document, a validator that runs before anything leaves, and a lookup that happens before you send. Everything else belongs to somebody else.

The examples are Laravel and Go because those are the two I get asked about. The shape is the point; translate it freely.

The part that is not code

Before any of it, your data model has to be able to express what the standard requires, and this is where the time actually goes. Four things break first, and they break in this order.

Money is a float. 1000.00 in a float is not exactly one thousand, and the arithmetic rules reconcile to the penny. Amounts travel as decimal strings. Fix the column type before anything else, because every rounding surprise downstream starts here.

The customer relationship is not an address. A counterparty needs a Peppol identifier, a scheme and a value, stored next to whatever you already key them on. There is no scheme for a Companies House number, so the obvious primary key for a UK business is useless for addressing one. That is chapter 3.

VAT is a single number. In the document it is a breakdown by category and rate, with a category code on every line and a reason wherever the rate is not standard. “We only do standard rate” describes this year’s customers, not your schema.

And there is no buyer reference. Peppol wants a buyer reference or a purchase order reference on every invoice, and plenty of systems carry neither, because nobody ever needed one to send a PDF.

In Laravel that is an afternoon:

Schema::table('invoices', function (Blueprint $table): void {
    $table->string('buyer_reference')->nullable();
    $table->string('purchase_order_reference')->nullable();
    $table->decimal('subtotal', 15, 2)->change();
    $table->decimal('vat', 15, 2)->change();
    $table->decimal('total', 15, 2)->change();
});

Schema::table('customers', function (Blueprint $table): void {
    $table->string('peppol_scheme')->nullable();
    $table->string('peppol_identifier')->nullable();
});

Schema::table('invoice_lines', function (Blueprint $table): void {
    $table->string('vat_category_code', 4)->default('S');
    $table->decimal('vat_rate', 5, 2);
});

Boring, and most of the actual work.

Producing the document

You have two routes. Build the UBL yourself with a library, or send your own shape as JSON and let something else produce it. Both are reasonable; the second is fewer decisions, and the arithmetic rules are where hand-rolled writers go wrong.

In PHP, num-num/ubl-invoice and josemmo/einvoicing both write UBL and are widely used. What neither tells you is whether the document you just built survives the Peppol rules, which is a separate problem and the next section.

For the conversion route, this guide’s own API has clients:

composer require einvoicing/laravel
go get github.com/JustSteveKing/einvoicing-go

In Laravel, with the facade:

use Einvoicing\Laravel\Facades\Einvoicing;

$conversion = Einvoicing::convert([
    'number' => $invoice->number,
    'issued' => $invoice->issued_at->toDateString(),
    'currency' => $invoice->currency,
    'buyer_reference' => $invoice->buyer_reference,
    // seller, buyer, lines, payment...
]);

Storage::put("invoices/{$invoice->id}.xml", $conversion->document);

In Go:

client := einvoicing.New(os.Getenv("EINVOICING_API_KEY"))

conversion, err := client.Convert(ctx, map[string]any{
    "target":  "peppol-bis-billing-3",
    "invoice": invoice,
})
if err != nil {
    return fmt.Errorf("converting %s: %w", invoice.Number, err)
}

Totals and the VAT breakdown are computed from the lines either way, which matters more than it sounds: the rules about totals reconciling are strict, and computing them twice in two places is how they stop agreeing.

Whatever produces your document, keep it behind a class of your own. You will change your mind about this layer at least once.

Validating in CI

This is the highest-value thing in the chapter, and the cheapest.

The rules are published and machine-readable, so a document that would be rejected can fail a build instead of a customer. The test you want is the one that breaks when an invoice becomes invalid:

it('produces a valid Peppol invoice', function (): void {
    $invoice = Invoice::factory()
        ->has(InvoiceLine::factory()->count(3), 'lines')
        ->create();

    $report = Einvoicing::validate(UblWriter::for($invoice));

    expect($report->valid)->toBeTrue(
        $report->errors()[0]->message ?? 'no findings',
    );
});

The same in Go:

func TestInvoiceIsValid(t *testing.T) {
    document := ubl.Write(fixtures.Invoice(t))

    report, err := client.Validate(t.Context(), document)
    if err != nil {
        t.Fatalf("validate: %v", err)
    }

    for _, finding := range report.Errors() {
        t.Errorf("%s: %s", finding.RuleID, finding.Message)
    }
}

Three things about that shape.

An invalid document is a result, not an exception. Both clients return a report with every finding on it rather than throwing on the first, because the second finding is usually the interesting one and you want the whole list in one run. Any library that throws will send you round the loop once per problem.

Fixtures for the awkward cases, one test per case. Reverse charge, zero-rated, exempt, a credit note, a foreign buyer. Those are the ones that break, and each deserves its own failing test rather than a shared one that could fail for six reasons.

And pin the ruleset. Validate against a published release, such as peppol-bis-billing-3.0.21, rather than whatever is current, or a release elsewhere turns your build red with nothing of yours changed. Chapter 5 covers the release cycle.

If you would rather not write the test, the Laravel package ships a command that exits non-zero on an invalid document, which drops into a pipeline as it stands:

php artisan einvoicing:validate storage/app/invoice.xml

There is also a validation rule, for the case where a document arrives from somewhere you do not control:

$request->validate([
    'invoice' => ['required', 'string', new PeppolDocument],
]);

Looking up before you send

A lookup answers two questions, and code that collapses them sends people looking in the wrong place:

if (! Einvoicing::canReceive($customer->peppol_identifier)) {
    // Either not on the network, or not for invoices. Two different
    // conversations with the customer.
}
participant, err := client.Participant(ctx, customer.PeppolID)
switch {
case err != nil:
    return err
case !participant.Registered:
    return ErrNotOnNetwork
case !participant.Accepts("Invoice-2::Invoice"):
    return ErrCannotReceiveInvoices
}

Cache the answer. Registrations change rarely and the lookup is a live network call; five minutes is a sensible default and is what the Laravel package uses. Re-check when a send fails, because a participant who moves Access Point keeps the same identifier while the endpoint behind it changes.

And do not treat a missing Peppol Directory entry as “not registered”. The Directory is optional; the SML and SMP are authoritative. This is the most common way a home-grown lookup goes wrong.

What you have to keep

Sending through a provider does not move the record-keeping.

Keep the document exactly as sent. Not your data plus a promise you could regenerate it, but the bytes. Your VAT obligations did not change because the invoice became XML, and a regenerated document is a different document the moment a rounding rule or a template changes.

Keep the transmission identifier your provider returns. When a document goes missing, that is the only string that lets their support team find it.

Keep the validation report, or at least the ruleset id and the timestamp. “It was valid when we sent it, against this release” is a sentence you will want to be able to prove.

What not to keep is just as real. A Peppol document carries names, addresses and bank details, so a full archive of every document you have ever sent is a data protection surface as well as a compliance asset. Retention that ends is a policy, not a bug. And sending a valid invoice to the wrong participant is an incident rather than a failed request, which is the other reason the lookup matters.

Where the seams go

Three interfaces, and the boundaries are not arbitrary.

A writer, turning your model into a document. Yours, changes with your schema.

A validator, which you call in CI and before sending. Stable, because the rules are published.

An Access Point adapter, a thin thing over one provider’s HTTP API. Every provider’s API differs in ways that have nothing to do with Peppol, and you will change provider one day. That file should be the most boring in the codebase, and the easiest to delete.

Notice that none of the three is “implement Peppol”. You are producing a document the network will carry and asking where to send it. The network belongs to corners 2 and 3, and chapter 2 explains why that is the good news.

The last chapter is not code at all: it is every claim about the UK mandate, labelled by how much we actually know, and it is updated on Budget day.

Checked against its sources on 18 September 2026

Get the rest by email

One email when the guide is finished, and an explainer on Budget day.

Developer docs

Getting a key, validating a document, and the API reference.