The UK is mandating e-invoicing in 2029, and your invoicing code is not ready
From 2029 every UK VAT invoice has to be structured data that passes about eleven hundred rules. What that means for the invoices table you already have, and the four fields that break first.
Somewhere in your application there is an invoices table. It has an id, a total, probably a currency, a relationship to a customer, and a line_items table hanging off it. You render it to a PDF with whatever you reached for at the time, email it, and mark it sent.
That code has about three years left.
From 2029, every VAT invoice in the UK has to be an e-invoice, which means structured data the receiving system can act on without anyone retyping it, and which satisfies a published set of rules before it goes anywhere. A PDF does not qualify, however carefully you designed it.
I have spent the last few weeks reading the standard properly, because some of my client work is in a market where this is already live. What surprised me was where the work is. I expected the hard part to be sending. It is not. It is the data model you already have.
What has actually been announced
There is a lot of confident writing about this, most of it from people who would like to sell you something, so it is worth separating what is confirmed from what is not.
Confirmed, in official sources:
- E-invoicing becomes mandatory for all VAT invoices from 2029. It covers B2B and B2G. B2C is out, and businesses that are not VAT registered are out.
- The model is decentralised. There is no real time reporting to HMRC in 2029, though a five corner model, where the tax authority gets a copy as the invoice is sent, could come later.
- Peppol is the core interoperability network, named in the Tax Update of 23 June 2026.
- The roadmap is published at the Budget on 28 October 2026.
Not confirmed, whatever you have read: the 1 April 2029 start date, which comes from vendors rather than HMRC. The phasing, where large businesses go first and everyone else follows in 2030, which is plausible and unpublished. And the UK data standard itself, which might be PINT UK or might be something UK specific. Nobody outside HMRC knows yet.
That distinction matters for the next three years, because the gap between announced and specified is where bad architectural decisions get made. I have written the longer version of this, with sources, as the first chapter of a guide.
It is not a 2029 problem
Three years is long enough to file something under later. Two things make that a mistake.
The first is that your neighbours are already live. Belgium’s B2B mandate started on 1 January 2026 and has been issuing fines since April. Germany has required businesses to receive e-invoices since 2025, with sending phased in from 2027. France went live in September 2026 on its own platform model. Ireland starts with large companies in late 2028. If your software has a single customer trading into any of those markets, your deadline was in the past.
The second is that the work is not where you think it is. Satisfying around eleven hundred validation rules is not a sprint you schedule for 2028. The rules reach into tax categories, party identifiers, payment terms and line level detail, which are exactly the places where invoicing code accumulates shortcuts. Every one of those shortcuts was a reasonable decision back when a human was going to read the output.
What an e-invoice actually is
Three layers, and it helps to keep them apart.
EN 16931 is the European standard, and it is a semantic model rather than a file format. It says an invoice has a seller, a buyer, a set of lines and a VAT breakdown, and it gives each of those a business term code. BT-1 is the invoice number. BT-10 is the buyer reference. BG-25 is an invoice line. There is no XML anywhere in it.
Peppol BIS Billing 3.0 narrows that down. Where the European standard allows a choice, the profile often makes one for you, and it adds rules of its own. That is the profile the UK picked by naming the network, and the current release is 3.0.21.
UBL 2.1 is the syntax, and it is the XML your code has to emit.
So “we support Peppol” means producing UBL 2.1 that satisfies the European rules and the Peppol rules, then handing it to something that puts it on the network. Validation runs in three passes in that order: schema, then EN 16931, then Peppol. The order is not decoration. A document that fails the schema tells you nothing about its business rules, so a validator that reports everything at once is mostly reporting noise.
The bits of your schema that are wrong
Here is roughly what I expect to find in a Laravel application:
$invoice = Invoice::create([ 'customer_id' => $customer->id, 'number' => 'INV-2026-0042', 'issued_at' => now(), 'currency' => 'GBP', 'subtotal' => 1000.00, 'vat' => 200.00, 'total' => 1200.00,]);There is nothing wrong with that as application code. As the source for a compliant e-invoice it has four problems, and I would bet on them in this order.
Money is a float. 1000.00 in a PHP float is not exactly one thousand, and the arithmetic rules are strict about totals reconciling to the penny. In the document, amounts are decimal strings. Fix the column type before anything else, because every rounding surprise further down starts here. Integer minor units, or decimal(15, 2) behind a value object, whichever you prefer, as long as it is exact.
The customer relationship is not an address. On the network a business is addressed by a scheme and a value, so a UK company is usually its VAT number under scheme 9932, written 9932:GB123456789. There is no scheme for a Companies House number. I have written a Companies House package, so that was the first thing I went looking for, and it is genuinely not there. The company number is the obvious primary key for a UK counterparty and it is useless for addressing one. You need somewhere to keep a Peppol identifier next to it, and you need to start asking customers for it in the same breath as their VAT number. I wrote up the schemes and the traps separately, including why the same company can exist twice on the network.
VAT is a single number. In the document it is a breakdown, grouped by category and rate, with a category code on every line and a reason wherever the rate is not standard. Zero rated, exempt, reverse charge and outside scope are four different categories with four different sets of requirements. “We only do standard rate” describes this year’s customers, not your schema.
There is no buyer reference. Peppol wants either a buyer reference or a purchase order reference on every invoice, and plenty of systems carry neither, because nobody has ever needed one to send a PDF. That is rule PEPPOL-EN16931-R003. If you want to see what a validator hands back, the rule has its own page.
None of that is an XML exporter. It is a migration you could write this 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.
For the XML itself, PHP is in reasonable shape already. num-num/ubl-invoice does around 44,000 installs a month and josemmo/einvoicing around 38,000, and either will write you a UBL document. What neither will tell you is whether the document you just built survives the Peppol rules, which is a different problem.
Addressing is a lookup, not an email
The other half of this has no equivalent in what you do today. You do not send to an address somebody gave you. You look the receiver up first, because the network has to tell you where they receive and what they accept.
That lookup goes through the SML, which is DNS based, to the receiver’s SMP, which lists the document types they accept and the Access Point sitting in front of them. Two different answers come back, and they are not the same thing: this business is not on the network, and this business is on the network but does not accept this document type. Collapse those into “sending failed” and your support team goes looking in the wrong place.
One trap before you build it yourself. There is a Peppol Directory, it is searchable, and it is optional. A business can be registered and perfectly reachable while being absent from it. Treat a missing directory entry as not registered and you will tell customers their counterparties cannot receive invoices when they can. The SML and SMP are the authority, and the four corner model explains where each of them sits.
Put validation in CI, not on a checklist
The useful thing about a published rule set is that you can fail a build with it.
Whatever writes your document, the test you want is the one that breaks when an invoice would be rejected:
it('produces a valid Peppol invoice', function (): void { $invoice = Invoice::factory() ->has(InvoiceLine::factory()->count(3), 'lines') ->create();
$report = Http::withToken(config('services.einvoicing.key')) ->withBody(UblWriter::for($invoice), 'application/xml') ->post('https://api.einvoicing.dev/v1/validations') ->json('data');
expect($report['valid'])->toBeTrue($report['findings'][0]['message'] ?? '');});The shape matters more than the service does. Fixtures for the awkward cases, one test per case, and a failure that names the rule that failed. An invalid invoice is a result rather than an exception, which is why I would push back on a library that throws: the second finding is often the interesting one, and an exception only ever carries the first.
Two things to insist on from whatever you use. That every finding carries the official rule text, because someone in finance will eventually ask where a requirement comes from and “our validator said so” is not an answer. And that it tells you which of the three layers failed, because a schema error and a Peppol rule error are not the same kind of problem.
What I would do this quarter
Not a rewrite. Three things, in order.
Fix money first. Decimal or integer minor units, never float, because everything else depends on the arithmetic reconciling exactly.
Add the two reference fields and somewhere to put a Peppol identifier. Cheap now, and they unblock the interesting work later.
Then produce one document and validate it. Not send it, just produce a single UBL invoice from real data and see what fails. That list is your actual backlog, and it tends to be shorter than people fear and different from what they guessed.
The Budget on 28 October should settle the data standard, the phasing, and whether service providers need accreditation. None of those three things depends on any of that.
What I am building
I am building einvoicing.dev, a headless API for the parts above: validate a document against every official rule and get the errors back in plain English with the fix, convert clean JSON into valid Peppol UBL, and check whether a business can receive before you try to send. It is deliberately not an Access Point. Sending is a competitive commodity already, and the document is the interesting problem.
The guide is free and being written chapter by chapter. On Budget day I will read the roadmap and write up what it means for people who build invoicing into software rather than for finance teams, and the waitlist is where that gets sent.
Keep Reading
Building Research
A desktop research workspace in Laravel and NativePHP. Streaming SSE into a queued job, distilling reports with a local model, and why cosine similarity cannot tell a paraphrase from a contradiction.
Aug 2026 · 22 min read
LaravelEvery Feature Touches Ten Files
Ten files open for a one line change is either layering working correctly or one idea smeared across a codebase. The count does not tell you which, and git history does.
Aug 2026 · 4 min read
LaravelIt Was Fine Until We Added A Second One
Every trigger in this series has been a second something. That is not a coincidence, and it is the only signal in here reliable enough to act on.
Aug 2026 · 8 min read