France made e-invoicing mandatory in stages. Since 1 September 2026, every VAT-registered company must be able to receive electronic invoices through an approved platform (a "plateforme agréée", formerly called PDP). Large companies already issue them. Small and medium businesses must issue them, and report their B2C sales, from 1 September 2027.

Before offering this to e-commerce clients, I did it for my own business. My invoicing runs on a Symfony app I wrote, and that app is wired to an approved platform's API: it issues, sends, tracks and receives invoices through the official network. Here is what I learned.

1. There are two flows, not one

A sale to a French company is an e-invoice: a structured file sent through the platform. A sale to a consumer, or to a foreign customer, is not an e-invoice. It is declared through e-reporting, as aggregated transaction data.

So the first thing my code does when an invoice is issued is to route it (simplified):

$buyer = $invoice->getUser(); $domesticB2B = 0 === strcasecmp($buyer->getCountry()->value, $this->sellerCountry) && $buyer->isClientB2B(); if ($domesticB2B) { $this->pdpTransmitter->transmit($invoice); // e-invoicing } else { $this->eReporting->report($invoice); // e-reporting (B2C, international) } 

For an online shop, the hard part is not the code. It is the data: do you actually know, for each order, whether the customer is a business? Many shops don't store it reliably.

2. Structured file for the platform, Factur-X for the human

The platform receives a structured XML file. I send UBL. My client receives a Factur-X PDF: a PDF/A-3 with the EN16931 CII XML embedded, generated with horstoeko/zugferd. Both come from the same invoice entity, so what the client reads and what the tax administration receives cannot drift apart.

3. An invoice now has a lifecycle

Once sent, an invoice moves through statuses: submitted, received, made available, rejected, paid. The platform pushes them by webhook, and I also poll as a fallback. I map the official status codes to three states my admin understands: pending, received, rejected. A rejected invoice nobody notices is an invoice nobody pays, so the status has to be visible in the admin.

4. Build a kill switch first

Everything that talks to the platform checks a feature flag first. While it is off, invoices are generated and stored, but nothing leaves the app. That let me test on real data without any risk of sending a wrong invoice to the tax network. I also pass my own invoice ID as an external reference, so every transmission can be traced back to the invoice that produced it.

5. Receiving is already mandatory

Most articles focus on issuing. But receiving has been mandatory since September 2026. My supplier invoices arrive through the same platform and my app lists and downloads them through the API. If you have done nothing yet, start there.

What this means for a Sylius or custom shop

Off-the-shelf platforms will ship modules of varying quality. A Sylius or custom Symfony shop can do better, because the order data is yours: route each order to e-invoicing or e-reporting, generate Factur-X from the order, push it through an approved platform's API, and store the lifecycle. Open source Factur-X generation for Sylius already exists. The missing pieces are usually the connection to a real platform and B2C e-reporting.

I'm Pierre-Arthur Demengel, a freelance Symfony and Sylius developer based in Metz, France. If you are working on this for a French shop, feel free to ask questions in the comments.