Omnibill is one PHP contract for transmitting and receiving electronic invoices. An application
asks every channel the same questions - send this invoice, where does it stand, what arrived for
me, this invoice is paid, where does this company receive its invoices, here is my e-reporting -
and gets the same answers back: a Flow to keep, an Inbox, a list of Addressing, a
Notification. Each channel is a package of its own: omnibill/email (the invoice attached to an
e-mail), omnibill/afnor (any approved platform that implements the standard API of XP Z12-013),
omnibill/chorus-pro (the public sector's portal, through PISTE).
$gateway = $registry->get('invoices');
$flow = $gateway->submit($invoice); // keep $flow->reference
$flow = $gateway->fetchStatus($flow); // ACCEPTED, DEPOSITED (200)...
$inbox = $gateway->receive($cursor); // suppliers' invoices, statuses about yours
$gateway->setStatus(new StatusChange(LifecycleStatus::PAID, amount: '1250.00', file: $cdar), $flow);
$gateway->lookup('702042755'); // where a company receives
$gateway->notify($request->getContent(), $request->headers->all()); // a platform's webhook, its signature checked
It is built around the French reform of electronic invoicing - its lifecycle statuses and their codes (200 to 213), its approved platforms (plateformes agréées), its national directory - and keeps the reform's words where they matter. A gateway carries files already conformant: Omnibill produces no Factur-X, UBL, CII or CDAR file and reads none beyond what it must to route one (a lifecycle message's status); producing them is the application's job (horstoeko/zugferd, for instance). It keeps no books either: the application stores the flows' references and statuses.
The design is Payum's, as in Omnibus: a gateway's factory fills a configuration with its name,
title, capabilities, options, its client (omnibill.api) and one action per request it answers;
the gateway runs the action that supports each request; a registry holds the application's
gateways by name. The core needs no framework: it requires symfony/http-client-contracts and
nothing else. A Symfony bundle is included (Symfony).
Published as omnisense/omnibill, formerly glitchr/omnibill and omniscient/omnibill: the package
replaces both names, so a composer.json that still requires one of them resolves to this package
and never installs two. The namespace is Omnibill\; the gateways keep the omnibill/ vendor and the
Omnibill\<Gateway>\ namespace.
When to use it
- A French company's site or back office that issues invoices and must, as the reform's dates come, move from e-mail to an approved platform without rewriting the code that sends them: the gateway's factory changes, the calls stay.
- A supplier of public entities depositing its invoices on Chorus Pro and following them.
- An application that receives its suppliers' invoices and the statuses of its own through an
approved platform, by polling (
receive()) or by webhook (notify()).
It is not an approved platform, and not an invoicing tool: it does not number, compute, sign, archive, or produce invoices.
Gateways
Three packages. The operations are what each gateway's supports() answers with its required
options set (the harness's bare lists them: harness).
| Package | Factory name | Carries | Submit | Fetch status | Receive | Set status | Lookup | Report | Notify | Approved platform | Public sector |
|---|---|---|---|---|---|---|---|---|---|---|---|
omnibill/email |
email |
Factur-X, UBL, CII | yes (an e-mail) | yes (recorded) | - | yes (recorded) | - | - | - | no | no |
omnibill/afnor |
afnor |
Factur-X, UBL, CII, CDAR, FRR; 100 MB | yes | yes | yes | yes (a CDAR file) | with directory_url |
yes | with webhook_key |
yes | yes |
omnibill/chorus-pro |
chorus-pro |
Factur-X, UBL, CII; 10 MB | yes | yes | - | - | - | - | - | no | yes |
None of the three was run against its real service except the e-mail, sent through a local SMTP server (Verified, and not).
How this manual is organised
- Installation: requirements, a first invoice in plain PHP.
- Symfony and Laravel: wiring the registry in a framework.
- Concepts: gateways, requests and actions, factories, the registry, the HTTP helpers.
- Models: invoices, flows, statuses, inbox, addressing, every field.
- Gateways: the contract and what each gateway does with it.
- Usage: submitting and following, receiving and webhooks, declaring statuses, the directory and e-reporting.
- Configuration reference: every option of every factory.
- Errors: the exceptions and when each is thrown.
- Testing: testing an application that uses Omnibill.
- The Docker harness: every gateway installed, the bare script.
- Writing a gateway.
- Verified, and not.
Proposer une modification docs/index.md
Installation
composer require omnisense/omnibill omnibill/email # the invoice by e-mail: the default
composer require omnibill/afnor # an approved platform, XP Z12-013
composer require omnibill/chorus-pro # the public sector, through PISTE
Requirements
- PHP 8.2 or later, with
mbstring(error messages are cut withmb_substr()). omnisense/omnibillrequiressymfony/http-client-contracts(^3.0, the interfaces, no client) and nothing else.omnibill/emailrequiressymfony/mailer(6.4, 7 or 8);omnibill/afnorrequiressymfony/http-client(6.4, 7 or 8) andext-dom(it reads lifecycle messages);omnibill/chorus-prorequiressymfony/http-client. Libraries, not a framework: each factory takes the application's mailer or client when it is given one.- For the Symfony bundle:
symfony/config,symfony/dependency-injection,symfony/http-kernel(6.4, 7 or 8), suggested, not required.
Omnibill runs the same in plain PHP, in a worker, in Laravel or Slim, and in Symfony, where a bundle does the wiring (Symfony).
Plain PHP
<?php // bill.php
require __DIR__.'/vendor/autoload.php';
use Omnibill\Email\EmailGatewayFactory;
use Omnibill\Model\Invoice;
use Omnibill\Model\LifecycleStatus;
use Omnibill\Model\Party;
use Omnibill\Model\StatusChange;
use Omnibill\Model\Syntax;
use Omnibill\Registry;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
$mailer = new Mailer(Transport::fromDsn(getenv('MAILER_DSN') ?: 'null://null'));
$registry = new Registry([new EmailGatewayFactory($mailer)], [
'invoices' => ['factory' => 'email', 'options' => ['from' => 'Atelier Tilleul <factures@atelier.example>']],
]);
// The file is the application's, already conformant: here a Factur-X made by horstoeko/zugferd, say.
$invoice = new Invoice(
content: "%PDF-1.7\n...",
filename: 'F-2026-042.pdf',
syntax: Syntax::FACTURX,
number: 'F-2026-042',
seller: new Party('Atelier Tilleul', siren: '123456789'),
buyer: new Party('Maison Érable', siren: '702042755', email: 'compta@erable.example'),
total: '1250.00',
);
$gateway = $registry->get('invoices');
$flow = $gateway->submit($invoice); // keep $flow->reference
printf("%s: %s to %s, %s\n", $flow->invoiceNumber, $flow->status->label(), $flow->data['to'], $flow->reference);
// Weeks later, the payment arrives: the application says so.
$flow = $gateway->setStatus(new StatusChange(LifecycleStatus::PAID, amount: '1250.00'), $flow);
foreach ($gateway->fetchStatus($flow)->history as $change) {
printf(" %s %s%s\n", $change->at->format('Y-m-d H:i'), $change->status->label(), null !== $change->status->code() ? ' ('.$change->status->code().')' : '');
}
$ php bill.php
F-2026-042: Envoyée to compta@erable.example, 08bf5ec24950b3d91b356c15f5a918d6@omnibill
2026-10-08 00:54 Envoyée
2026-10-08 00:54 Encaissée (212)
(2026-10-08; run again on 2026-10-26 on the packages' checkouts, PHP 8.4, the same lines with that
day's times and another reference. With MAILER_DSN=smtp://... the e-mail leaves for real - the
harness sent one to a local SMTP server on 2026-10-08, the PDF attached.)
That is all there is to it:
- a factory per gateway package, which takes what its gateway works with - a mailer for the e-mail, an HTTP client for the platforms;
- the registry, built by hand from the factories and the gateways' options, by name;
- the gateways it gives, each answering the same questions (gateways).
null://null is Symfony Mailer's transport that sends nothing: the script runs as it is. The
statuses are kept in the factory's status store - here, with none given, this process's memory
(InMemoryStatusStore); an application gives its own, a table (usage).
In a framework
- Symfony:
Omnibill\Bridge\Symfony\OmnibillBundleregisters the gateway factories (the e-mail's on the application's mailer, the platforms' on itshttp_client), builds the registry fromconfig/packages/omnibill.yamland makes each gateway injectable by its name: Symfony. - Laravel: there is no bridge package; a service provider builds the registry once: Laravel.
- Any other: build the
Registryonce, where the framework builds its services.
No class of a framework is loaded on the way - a test of this package checks it in a process of
its own (Tests/BareTest.php), and so does docker compose run --rm omnibill bare
(harness).
What the application does around a gateway
- Produce the file: a Factur-X, a UBL or CII invoice - not Omnibill's job.
- Submit it, and keep the flow's reference with the invoice, and the gateway's name.
- Follow it:
fetchStatus()now and then, or a platform's webhook (notify()), andreceive()for what comes in - suppliers' invoices, statuses about your own - from the cursor kept since last time. - Declare what only the application knows - an invoice paid (
LifecycleStatus::PAID, the reform's "Encaissée"), a supplier's invoice refused - withsetStatus(). A platform carries it as a lifecycle message (CDAR) the application produced; the e-mail gateway records it.
Errors
The whole list, and what each gateway maps to which: Errors.
| Exception | When |
|---|---|
NotSupportedException |
the gateway does not do that: supports() says so beforehand |
InvalidConfigException |
a gateway not configured, a factory not installed, an option missing; an invoice without what the gateway needs (a buyer's e-mail, a number) |
InvalidKeyException |
the platform refused the application's credentials |
UnreachableException |
the platform did not answer: nothing was transmitted, try again |
InvalidNotificationException |
a callback whose signature does not hold, or too old |
ProviderException |
the platform refused the request (the three above extend it) |
All implement Omnibill\Exception\OmnibillException.
Proposer une modification docs/installation.md
Symfony
Omnibill runs without a framework (installation); in a Symfony application its
bundle does the wiring. Its components - symfony/config, symfony/dependency-injection,
symfony/http-kernel - are not required by omnisense/omnibill: the application has them, and
nothing of them is loaded outside Symfony. The bundle is part of the core
(Omnibill\Bridge\Symfony\OmnibillBundle); omnibridge has no Omnibill bridge, and none is needed.
Registering the bundle
Register Omnibill\Bridge\Symfony\OmnibillBundle (no Flex recipe):
// config/bundles.php
return [
// ...
Omnibill\Bridge\Symfony\OmnibillBundle::class => ['all' => true],
];
Configuration
# config/packages/omnibill.yaml
omnibill:
gateways: # by name: a factory and its options
invoices:
factory: email
options: { from: 'Atelier Tilleul <factures@atelier.example>', bcc: '%env(default::INVOICE_ARCHIVE)%' }
platform:
factory: afnor
options:
flow_url: '%env(PA_FLOW_URL)%'
directory_url: '%env(PA_DIRECTORY_URL)%'
token_url: '%env(PA_TOKEN_URL)%'
client_id: '%env(PA_CLIENT_ID)%'
client_secret: '%env(PA_CLIENT_SECRET)%'
webhook_key: '%env(default::PA_WEBHOOK_KEY)%'
public:
factory: chorus-pro
options: { client_id: '%env(PISTE_CLIENT_ID)%', client_secret: '%env(PISTE_CLIENT_SECRET)%', login: '%env(CPRO_LOGIN)%', password: '%env(CPRO_PASSWORD)%' }
The whole tree, from the bundle's configure():
| Key | Type | Default | |
|---|---|---|---|
omnibill.gateways |
map, keyed by the gateway's name | {} |
the application's gateways |
omnibill.gateways.<name>.factory |
string, required, not empty | - | a factory's name: email, afnor, chorus-pro, or an application's own factory's getName() |
omnibill.gateways.<name>.options |
any value (a map) | [] |
handed as they are to the factory's create() (Configuration) |
Processed with nothing set, the tree is {"gateways": []}; an empty factory is refused when the
container compiles (The path "omnibill.gateways.x.factory" cannot contain an empty value, but got "".). The options are not validated then, but when the gateway is built: a required option missing
or empty is an InvalidConfigException naming it, the first time the gateway is asked for.
Services
Every omnibill/* package installed registers its factory, autowired: omnibill/email gets
the application's MailerInterface and, when it has one, a StatusStoreInterface of its own (a
table: the statuses outlive the process); the platforms get the application's http_client.
#[AsAlias(StatusStoreInterface::class)]
final class InvoiceStatusRepository extends ServiceEntityRepository implements StatusStoreInterface { /* ... */ }
The bundle knows Omnibill\Email\EmailGatewayFactory, Omnibill\Afnor\AfnorGatewayFactory and
Omnibill\ChorusPro\ChorusProGatewayFactory, and registers those whose class exists. A factory's
$http argument is the http_client service (null when the application has none: the factory makes
its own client); its other arguments are autowired - the e-mail's MailerInterface and
StatusStoreInterface, each null when the application has no such service (no mailer: building the
e-mail gateway is an InvalidConfigException; no store: this process's memory, lost at the end of
the request).
| Service id | Class | Autowiring | |
|---|---|---|---|
Omnibill\Registry |
Registry |
Registry $registry |
every configured gateway by name: get(), has(), names(), options(), all(), create() with other options. Public. |
omnibill.gateway.<name> |
GatewayInterface |
GatewayInterface $<name> |
one gateway, built by Registry::get('<name>') the first time it is injected |
| the factories | their class | by their class | tagged omnibill.gateway_factory |
The argument's name selects the gateway: GatewayInterface $platform is omnibill.gateway.platform
(Symfony's named autowiring aliases). A name that is not a valid PHP variable name (chorus-pro) is
still reachable by its service id or by the registry.
Nothing is built when the container compiles: a gateway is built the first time it is asked for,
and an option left empty only shows then (InvalidConfigException). An application's own gateway
- a class implementing
GatewayFactoryInterface- is registered too, autoconfigured (taggedomnibill.gateway_factory), and itsgetName()can be named as afactory(Writing a gateway). The bundle registers no command, event, route or Twig function.
A platform's webhook
#[Route('/invoices/webhook', methods: ['POST'])]
public function __invoke(Request $request, GatewayInterface $platform): Response
{
try {
$notification = $platform->notify($request->getContent(), $request->headers->all());
} catch (InvalidNotificationException) {
return new Response('', 400);
}
$this->bus->dispatch(new ReceiveInvoices()); // receive() from the cursor kept, out of the request
return new Response('', 200);
}
The route above is the application's: the bundle registers none. Answer fast and do the work out
of the request; a callback sent twice has the same Notification::$id - deduplicate on it
(Receiving).
The day a site's company must issue through an approved platform, invoices changes factory, from
email to afnor: the code that submits stays.
Proposer une modification docs/symfony.md
Laravel
omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnibill provider, and there
is no other Laravel package for it. The registry is plain PHP: a service provider of the application
builds it once, from a config file. omnibill/email takes a Symfony Mailer (symfony/mailer, which
it requires; Laravel's own mailer is built on it, but the factory takes
Symfony\Component\Mailer\MailerInterface), the platforms a symfony/http-client client.
// config/omnibill.php
return [
'mailer_dsn' => env('OMNIBILL_MAILER_DSN', 'smtp://localhost:25'),
'gateways' => [
'invoices' => ['factory' => 'email', 'options' => ['from' => 'Atelier Tilleul <factures@atelier.example>']],
'platform' => ['factory' => 'afnor', 'options' => [
'flow_url' => env('PA_FLOW_URL'),
'directory_url' => env('PA_DIRECTORY_URL'),
'token_url' => env('PA_TOKEN_URL'),
'client_id' => env('PA_CLIENT_ID'),
'client_secret' => env('PA_CLIENT_SECRET'),
'webhook_key' => env('PA_WEBHOOK_KEY'),
]],
],
];
// app/Providers/OmnibillServiceProvider.php
namespace App\Providers;
use App\Billing\InvoiceStatusStore; // the application's table, implementing Omnibill\Email\StatusStoreInterface
use Illuminate\Support\ServiceProvider;
use Omnibill\Afnor\AfnorGatewayFactory;
use Omnibill\Email\EmailGatewayFactory;
use Omnibill\Registry;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
final class OmnibillServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Registry::class, function ($app): Registry {
$mailer = new Mailer(Transport::fromDsn(config('omnibill.mailer_dsn')));
return new Registry([
new EmailGatewayFactory($mailer, $app->make(InvoiceStatusStore::class)),
new AfnorGatewayFactory(HttpClient::create()),
], config('omnibill.gateways'));
});
}
}
Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject the registry:
use Omnibill\Registry;
final class SendInvoice // a queued job
{
public function handle(Registry $registry): void
{
$flow = $registry->get('invoices')->submit($this->invoice);
// store $flow->reference with the invoice
}
}
A webhook route reads the raw body and the headers: $gateway->notify($request->getContent(), $request->headers->all()), and answers 400 on an InvalidNotificationException.
Differences from the Symfony bundle: list in the provider every factory the application uses, and
ask the registry for a gateway by name (no injection by argument name). Unlike Symfony's
%env(default::X)%, an env() left unset is null, and a required option that is null is refused
when the gateway is built. Configuration caching keeps working: the options are plain arrays. This
page was written from the registry's API; it was not run in a Laravel application.
Proposer une modification docs/laravel.md
Concepts
Gateways
A gateway is one way to transmit and receive invoices, configured: Omnibill\GatewayInterface.
| Method | Request | Answers |
|---|---|---|
getName(): string |
- | the factory's name: email, afnor, chorus-pro |
getTitle(): string |
- | a readable name: E-mail, Approved platform (XP Z12-013), Chorus Pro |
capabilities(): Capabilities |
- | what it carries, and whether it is an approved platform's channel |
supports(string $request): bool |
- | whether it answers that request class (Request\Receive::class...) |
execute(Request $request): Request |
any | the same request, answered |
submit(Invoice $invoice, ?string $trackingId = null): Flow |
Request\Submit |
the invoice's flow: keep its reference |
fetchStatus(Flow $flow): Flow |
Request\FetchStatus |
the flow as the gateway has it now |
receive(?string $cursor = null, int $limit = 25): Inbox |
Request\Receive |
what arrived since the cursor |
setStatus(StatusChange $change, ?Flow $flow = null): Flow |
Request\SetStatus |
the flow that carried it, or the invoice's, updated |
lookup(string $identifier): array |
Request\Lookup |
list<Addressing>: where a SIREN or SIRET receives |
report(EReport $report, ?string $trackingId = null): Flow |
Request\Report |
the report's flow |
notify(string $body, array $headers = []): Notification |
Request\Notify |
a webhook's call, its signature checked |
Each typed method is execute(new Request\...) and the request's result. A request no action
supports is a NotSupportedException (The "chorus-pro" gateway does not support Receive.);
supports() says so beforehand, by asking each action about an instance of the class built without
its constructor.
Omnibill\Gateway is the one implementation: new Gateway(string $name, string $title, Capabilities $capabilities, array $actions). execute() hands the request to the first action that
supports it; an action that returns without setting a result is a ProviderException (<class> left the request unanswered.).
Requests
Omnibill\Request\Request is abstract: setResult(mixed), getResult(), isAnswered(). The
requests, with their public readonly properties and their typed getter:
| Request | Properties | Result |
|---|---|---|
Submit |
invoice (Invoice), trackingId (?string) |
getFlow(): ?Flow |
FetchStatus |
flow (Flow) |
getFlow(): ?Flow |
Receive |
cursor (?string), limit (int, 25) |
getInbox(): ?Inbox |
SetStatus |
change (StatusChange), flow (?Flow) |
getFlow(): ?Flow |
Lookup |
identifier (string) |
getAddressings(): ?array |
Report |
report (EReport), trackingId (?string) |
getFlow(): ?Flow |
Notify |
body (string), headers (array<string, string|string[]>) |
getNotification(): ?Notification; header(string $name): ?string whatever the case it came in |
Actions
Omnibill\Action\ActionInterface: supports(Request $request): bool and execute(Request $request): void, which sets the request's result or throws an OmnibillException. An action that
works through the gateway's client implements Action\ApiAwareInterface (setApi(object $api));
Action\ApiAwareTrait holds it in $this->api and refuses a client that is not of the class it
names in $this->apiClass (InvalidConfigException).
Factories and their Config
Omnibill\GatewayFactoryInterface: getName(): string and create(array $options = []): GatewayInterface. The packages extend Omnibill\GatewayFactory, whose create():
- fills a
Configwith the options given and the factory'spopulateConfig()defaults under them; - checks every key of
omnibill.required_optionsis set and not empty (InvalidConfigException:The "afnor" gateway needs: client_secret.); - builds the client:
omnibill.api, a closure of theConfigcalled now, or an object; - reads
omnibill.capabilities(aCapabilities, or a closure giving one; missing:InvalidConfigException); - collects every
omnibill.action.<name>: anActionInterface, or a closure of theConfiggiving one - or null, which leaves the action out (that is howafnordropslookupwithout adirectory_url); eachApiAwareInterfaceaction is handed the client; - returns a
Gatewayofomnibill.factory_name,omnibill.factory_title, the capabilities and the actions.
Building calls nothing; a factory reached over HTTP takes its client in its own constructor.
Omnibill\Config is an ArrayObject of the options, with:
| Method | |
|---|---|
defaults(array $defaults): self |
sets each key not set yet |
validateNotEmpty(array $keys): void |
InvalidConfigException naming the keys unset, null, '' or [] |
get(string $key, mixed $default = null) |
|
bool(string $key): bool |
a boolean, or "1", "true", "yes", "on" (any case) as an environment variable gives it |
list(string $key): list<string> |
a list, each value split on commas and trimmed: 'a@x, b@x' is two |
The registry
Omnibill\Registry, built from the factories and the gateways' configuration by name:
| Method | |
|---|---|
get(string $name): GatewayInterface |
built once and kept. No "x" gateway; configured: invoices., No "x" factory for the "y" gateway; installed: email, afnor. |
create(string $name, array $overrides = []): GatewayInterface |
built afresh, the configured options with $overrides over them (an instance typed in a back office); not kept |
has(string $name): bool |
whether the name is configured |
options(string $name): array |
its configured options |
names(): list<string>, all(): array<string, GatewayInterface> |
|
factories(): list<string> |
the factories installed: what factory may name |
HTTP helpers
Two classes of the core every platform gateway calls through:
Omnibill\Http\Answer::send(HttpClientInterface $http, string $provider, string $method, string $url, array $options = []): Answer- one request, read whole, with a 10-second timeout unless the options give one. No connection, a timeout, a transport error, an HTTP 5xx or a 429 is anUnreachableException(the platform said nothing about the request: try again); any other answer comes back as anAnswer:status,headers(lower-case names),body,header($name), andjson()(the body as a JSON object, or aProviderException).Omnibill\Http\ClientCredentials- an OAuth 2.0 access token by the client credentials grant:new ClientCredentials($http, $provider, $tokenUrl, $clientId, $clientSecret, ?$scope, ?$clock),token()(asked once, kept until 60 seconds beforeexpires_in- 3600 when the endpoint does not say),reset()(forget it: after a 401). A 400, 401 or 403 from the token endpoint, or an answer withoutaccess_token, is anInvalidKeyException.
The models these requests carry: Models.
Proposer une modification docs/concepts.md
Models
All models are readonly; times are DateTimeImmutable; amounts are decimal strings
("1250.00"). They carry what a gateway needs: a file and identifiers, never an entity of the
application.
Invoice
| Property | |
|---|---|
content, filename |
the file's bytes, its name with the extension |
syntax |
Syntax::FACTURX, UBL, CII (also CDAR for a lifecycle message, FRR for e-reporting) |
number |
the invoice's number: what its statuses are followed by |
seller, buyer |
Party: name, SIREN, SIRET, electronic address, routing code, e-mail, country |
issuedAt, total, currency |
what the application knows; not read from the file |
profile, rule |
the platform's flowProfile (Basic, CIUS, Extended-CTC-FR) and processingRule (B2B, B2BInt, B2C, B2G...), where it asks |
new Invoice(string $content, string $filename, Syntax $syntax, ?string $number = null, ?Party $seller = null, ?Party $buyer = null, ?\DateTimeImmutable $issuedAt = null, ?string $total = null, ?string $currency = 'EUR', ?string $profile = null, ?string $rule = null).
Invoice::fromFile($path, $syntax, $number, $buyer) reads a file (an \InvalidArgumentException
when it cannot: Cannot read <path>.), its name the file's base name, its syntax the one given, else
Factur-X for a .pdf and CII for anything else - a UBL file must say Syntax::UBL. sha256() is
the content's hash, size() its length in bytes.
Party
new Party(?string $name = null, ?string $siren = null, ?string $siret = null, ?string $electronicAddress = null, ?string $routingCode = null, ?string $email = null, ?string $country = 'FR'): identifiers only. The e-mail gateway sends to the buyer's email; the platforms read no
party (the file says who is who).
Syntax
| Case | Value | mimeType() |
extension() |
|
|---|---|---|---|---|
FACTURX |
Factur-X |
application/pdf |
pdf |
a PDF/A-3 with its CII XML inside (Factur-X, ZUGFeRD) |
UBL |
UBL |
application/xml |
xml |
UBL 2.1 |
CII |
CII |
application/xml |
xml |
UN/CEFACT Cross Industry Invoice |
CDAR |
CDAR |
application/xml |
xml |
a lifecycle status message |
FRR |
FRR |
application/xml |
xml |
an e-reporting file |
The values are XP Z12-013's flowSyntax names.
Flow
One transmission on a gateway: an invoice, a status, a report, sent or received.
| Property | |
|---|---|
reference |
the gateway's name for it: a flow id, a deposit number, a message id. Keep it |
kind |
FlowKind::INVOICE, LIFECYCLE, REPORT, OTHER |
direction |
Direction::OUT (sent), IN (received) |
state |
the platform's acknowledgement: FlowState::PENDING, ACCEPTED, REJECTED |
status, history |
the invoice's latest LifecycleStatus, and every StatusChange known, oldest first |
errors |
why the platform rejected it |
trackingId, invoiceNumber, submittedAt, updatedAt, name, data |
with(state, status, history, errors, updatedAt, data) gives the same flow updated (each argument
null keeps the flow's). The enums:
| Enum | Cases (value) |
|---|---|
FlowKind |
INVOICE (invoice), LIFECYCLE (lifecycle), REPORT (report), OTHER (other) |
Direction |
OUT (out: sent by the application), IN (in: received) |
FlowState |
PENDING (pending: received, not checked), ACCEPTED (accepted: checked and taken), REJECTED (rejected: refused by the platform's checks, see errors) |
The flow's state is the platform's technical acknowledgement of the file; what the recipient did with
the invoice is its status.
LifecycleStatus
The reform's statuses, as the DGFiP's external specifications list them (general file v3.2, 2026-04-30):
| Case | Code | Label | |
|---|---|---|---|
DEPOSITED |
200 | Déposée | mandatory: the issuing platform checked the invoice |
ISSUED |
201 | Émise par la plateforme | |
RECEIVED |
202 | Reçue par la plateforme | |
MADE_AVAILABLE |
203 | Mise à disposition | |
TAKEN_OVER |
204 | Prise en charge | |
APPROVED |
205 | Approuvée | |
PARTIALLY_APPROVED |
206 | Approuvée partiellement | |
DISPUTED |
207 | En litige | |
SUSPENDED |
208 | Suspendue | |
COMPLETED |
209 | Complétée | |
REFUSED |
210 | Refusée | mandatory: the recipient refuses it |
PAYMENT_SENT |
211 | Paiement transmis | |
PAID |
212 | Encaissée | mandatory: the supplier received the payment |
REJECTED |
213 | Rejetée | mandatory: a platform's checks found an anomaly |
SENT |
- | Envoyée | the family's own: sent outside any platform (e-mail) |
Values are lower-case snake case (deposited, made_available, payment_sent...). code() is the
reform's code (null for SENT), label() the French name above, isMandatory() true for 200, 210,
212 and 213, isFinal() true for REFUSED, REJECTED and PAID (nothing follows them).
LifecycleStatus::fromCode(int|string $code) reads 212, "212" or "fr:212" (leading non-digits
are dropped); a code that is not one of 200 to 213 is null.
StatusChange
One step: status, invoiceNumber, at, reason, amount (for a payment), code (the
platform's own), and file/filename - the lifecycle message itself (CDAR), where the platform
carries one.
Inbox and Received
receive($cursor) answers an Inbox: items (each a Received: the flow, and an invoice or a
status), cursor (give it to the next receive()), more. invoices() and statuses() sort
them.
Addressing
A line of the national directory: siren, siret, routingCode, electronicAddress (the
addressing identifier), name, platform (WK: an approved platform, DFH: the public portal),
status (Enabled, Disabled, Upcoming), data. isEnabled().
EReport
An e-reporting file: content, filename, syntax (FRR), rule (B2C, B2BInt...).
Notification
A platform's callback, its signature checked: event (Event::FLOW_UPDATED flow_updated: a flow
was taken or rejected; RECEIVED received: something arrived for the application; OTHER
other), the flow, at, id (what a callback sent twice is recognised by), data (the
platform's payload).
Capabilities
syntaxes (what it carries), approved (an approved platform's channel), publicSector,
maxSize (bytes). Defaults: Factur-X, UBL and CII, not approved, not public sector, no size.
carries(Syntax), toArray() (the syntaxes as their values, and the other fields). The operations
are asked of the gateway: supports(Receive::class).
maxSize is said, not enforced: no gateway refuses a file for its size before sending it; the
platform does (Chorus Pro: codeRetour 20001).
Proposer une modification docs/models.md
Gateways
One contract, Omnibill\GatewayInterface, whoever carries the invoices:
| Method | Request | Answers |
|---|---|---|
submit(Invoice) |
Request\Submit |
its Flow: keep the reference |
fetchStatus(Flow) |
Request\FetchStatus |
the flow, updated: the platform's acknowledgement, the invoice's status |
receive(?cursor) |
Request\Receive |
an Inbox: suppliers' invoices, statuses about yours, the next cursor |
setStatus(StatusChange, ?Flow) |
Request\SetStatus |
the flow that carried it |
lookup(siren or siret) |
Request\Lookup |
list<Addressing>: where the company receives |
report(EReport) |
Request\Report |
its Flow |
notify($body, $headers) |
Request\Notify |
a Notification, once the callback's signature holds |
capabilities() says what it carries; supports(Request::class) what it answers. Each method is
a shortcut for execute(new Request\...). What a gateway does not do throws
NotSupportedException - nothing is guessed, nothing silently skipped.
omnibill/email |
omnibill/afnor |
omnibill/chorus-pro |
|
|---|---|---|---|
| Carries | Factur-X, UBL, CII, attached to an e-mail | Factur-X, UBL, CII, CDAR, FRR: flows | Factur-X, UBL, CII: deposited flows |
submit |
the e-mail sent: SENT |
POST /v1/flows: PENDING |
deposer/flux: PENDING |
fetchStatus |
the statuses recorded | the acknowledgement: Ok DEPOSITED, Error REJECTED |
the invoice's statut, read as the reform's |
receive |
- | flows coming in, their files | - |
setStatus |
recorded by hand | the application's CDAR, as a flow | - |
lookup |
- | the Directory Service | - |
report |
- | an FRR flow | - |
notify |
- | webhooks, Afnor-Signature checked |
- |
| Approved platform | no | yes | the public sector's portal |
| Needs | a mailer, a sender | the platform's addresses, OAuth 2.0 client credentials | a PISTE application, a technical account |
Each gateway's details: submitting and following, receiving, statuses, the directory and e-reporting, and the gateways' own manuals.
Following an invoice
$flow = $gateway->submit($invoice);
$row->gateway = 'platform';
$row->reference = $flow->reference;
// later, from a cron job or on the platform's webhook
$flow = $gateway->fetchStatus(new Flow($row->reference, invoiceNumber: $row->number));
if ($flow->state === FlowState::REJECTED) {
// $flow->errors: what the platform's checks found
}
// what came in since last time
$inbox = $gateway->receive($row->cursor);
foreach ($inbox->items as $received) {
$received->invoice; // a supplier's invoice: its file, to read with your own tools
$received->status; // a status about one of yours: ->invoiceNumber, ->status, ->reason
}
$row->cursor = $inbox->cursor;
Writing a gateway
A package omnibill/<platform>: a factory extending Omnibill\GatewayFactory that fills a
Config - its name, title, capabilities, the options it needs, its client (omnibill.api) and
one action per request it answers:
final class PeppolGatewayFactory extends GatewayFactory
{
// A platform reached over HTTP takes its client here: the application's, a mock in a test.
public function __construct(private readonly ?HttpClientInterface $http = null)
{
}
protected function populateConfig(Config $config): void
{
$config->defaults([
'omnibill.factory_name' => 'peppol',
'omnibill.factory_title' => 'Peppol access point',
'omnibill.required_options' => ['api_key'],
'omnibill.capabilities' => new Capabilities([Syntax::UBL]),
'omnibill.api' => fn (Config $c) => new Api((string) $c['api_key'], $this->http ?? HttpClient::create()),
'omnibill.action.submit' => new SubmitAction(),
'omnibill.action.fetch_status' => new FetchStatusAction(),
]);
}
}
An action implements Action\ActionInterface (supports(Request), execute(Request) which sets
the request's result) and, to be handed the client, Action\ApiAwareInterface. Call the platform
through Omnibill\Http\Answer::send() (no answer, a server error or a quota: an
UnreachableException) and Omnibill\Http\ClientCredentials for an OAuth 2.0 token. Leave out
the actions the platform's documentation does not show. The whole guide, with an example run:
Writing a gateway.
Proposer une modification docs/gateways.md
Submitting and following
The examples of these usage pages come from one script per gateway, run on 2026-10-26 on the
packages' checkouts (PHP 8.4, no network) with a MockHttpClient serving the packages' recorded
answers (docker/harness/config/recorded.php, the gateways' Tests/Fixtures); the > lines are the
mock's log of each request. The platforms' answers are fixtures written from their published
schemas: the ids and dates are theirs, not the call's.
Submitting
use Omnibill\Model\Invoice;
use Omnibill\Model\Party;
use Omnibill\Model\Syntax;
$invoice = new Invoice(
content: file_get_contents('F-2026-042.pdf'), // the application's Factur-X
filename: 'F-2026-042.pdf',
syntax: Syntax::FACTURX,
number: 'F-2026-042', // what the statuses are followed by
buyer: new Party('Maison Érable', siren: '702042755', email: 'compta@erable.example'),
profile: 'Basic', // flowProfile, where the platform asks
);
$flow = $gateway->submit($invoice, trackingId: 'order-1234');
$row->gateway = 'platform';
$row->reference = $flow->reference; // keep it, with the gateway's name
What each gateway does:
| Gateway | Call | The flow answered |
|---|---|---|
email |
an e-mail to the buyer's email, the file attached |
ACCEPTED, status SENT, reference the e-mail's Message-ID (<32 hex>@omnibill), data['to'] the address |
afnor |
POST /v1/flows, multipart: flowInfo (JSON) and file |
PENDING, reference the platform's flowId |
chorus-pro |
POST /v1/deposer/flux: the file in base64, its name, its syntaxeFlux |
PENDING, reference numeroFluxDepot |
afnor's flowInfo is trackingId, name (the file name), processingRule (the invoice's
rule, else the gateway's rule option), flowSyntax, flowProfile (the invoice's profile,
else the profile option), and the file's sha256; empty fields are left out. The flow's state is
PENDING whatever the answer says; its other fields are the platform's answer (trackingId as the
platform echoes it).
submit
> POST https://auth.pa.example/auth-service/acme/token
> POST https://api.flow.pa.example/flow-service/v1/flows
8f1d2c3b-4a5e-4f60-9a7b-1c2d3e4f5a6b pending invoice order-42
(the recorded answer's trackingId is order-42.) The token is asked once, before the first call,
and kept.
chorus-pro maps the syntax to Chorus Pro's syntaxeFlux (the syntaxes option: Factur-X
IN_DP_E2_CII_FACTURX, UBL IN_DP_E1_UBL_INVOICE, CII IN_DP_E1_CII_16B); a syntax it has no name
for is an InvalidConfigException (Chorus Pro takes no CDAR file through this gateway.). It sends
avecSignature: false. The flow's submittedAt is the answer's dateDepot.
> POST https://sandbox-oauth.piste.gouv.fr/api/oauth/token
> POST https://sandbox-api.piste.gouv.fr/cpro/factures/v1/deposer/flux {"fichierFlux":"JVBERi0xLjcKLi4u","nomFichier":"F-2026-042.pdf","syntaxeFlux":"IN_DP_E2_CII_FACTURX","avecSignature":fal
CPP0021100000000000000023 pending 2026-10-08 00:00
email needs the invoice's buyer and the buyer's e-mail (InvalidConfigException without); the
subject and the body are the gateway's templates with {number}, {seller}, {buyer}, {total},
{currency} filled; a mailer that fails is an UnreachableException and nothing is recorded.
The first run of Installation shows it.
No gateway checks the file's size or content before sending: a file the platform refuses comes back
as a ProviderException (Chorus Pro: codeRetour 20001, GDP_MSG_11.022, too large) or as a
REJECTED flow once checked.
Following
use Omnibill\Model\Flow;
use Omnibill\Model\FlowState;
$flow = $gateway->fetchStatus(new Flow($row->reference, invoiceNumber: $row->number));
if (FlowState::REJECTED === $flow->state) {
$flow->errors; // what the platform's checks found
}
$flow->status; // the invoice's latest LifecycleStatus
$flow->history; // every StatusChange known, oldest first
fetchStatus() takes the flow kept - rebuilt from the stored reference and number is enough - and
answers it updated:
| Gateway | Asks | Answers |
|---|---|---|
email |
the status store, by the reference | the recorded statuses as history, the latest as status |
afnor |
GET /v1/flows/{flowId}?docType=Metadata |
the acknowledgement: Ok is ACCEPTED and, for an invoice sent, DEPOSITED (200); Error is REJECTED and REJECTED (213), with the details as errors (<reasonCode>: <reasonMessage> (<item>)); anything else PENDING. The status is added to the history given when it is not there yet |
chorus-pro |
POST /v1/rechercher/fournisseur with the invoice's number |
the invoice Chorus Pro integrated - the one deposited in this flow when several bear the number, else the first found: ACCEPTED, its statut read as a reform status (the code kept), added to the history when it changed. None found yet: the flow as it was |
fetchStatus
> GET https://api.flow.pa.example/flow-service/v1/flows/8f1d2c3b-4a5e-4f60-9a7b-1c2d3e4f5a6b?docType=Metadata
accepted deposited, history: deposited (200)
> POST https://sandbox-api.piste.gouv.fr/cpro/factures/v1/rechercher/fournisseur {"numeroFacture":"F-2026-042"}
accepted made_available, history: made_available (MISE_A_DISPOSITION)
Chorus Pro finds an invoice by its number: a flow without invoiceNumber is an
InvalidConfigException. On an approved platform, the acknowledgement is all fetchStatus()
tells: the recipient's statuses arrive as lifecycle flows, through receive().
Chorus Pro's statuses, and the reading of each: the
chorus-pro manual.
A dateDepot or dateHeureEtatCourant that PHP cannot read as a date is no date in
omnibill/chorus-pro: submittedAt the time of the deposit, the status change's at null.
Proposer une modification docs/usage-submitting.md
Receiving and webhooks
Only omnibill/afnor receives: an approved platform delivers the application's suppliers'
invoices and the statuses of its own invoices as flows coming in. email and chorus-pro throw
NotSupportedException (supports(Receive::class) is false).
Receiving, from a cursor
$inbox = $gateway->receive($row->cursor, limit: 25);
foreach ($inbox->items as $received) {
if ($received->isInvoice()) {
$received->invoice; // a supplier's invoice: its file, to read with the application's tools
} elseif ($received->status) {
$received->status; // a status about one of the application's invoices
}
$received->flow; // the flow it came in
}
$row->cursor = $inbox->cursor; // the next receive() starts there
$inbox->more; // true: there is more, call again
receive() sends POST /v1/flows/search with where.flowDirection: [In], limit (1 to 100) and
the cursor, then downloads each flow's file (GET /v1/flows/{flowId}?docType=Original): one call
per flow. Each flow becomes a Received:
- a lifecycle flow (
flowSyntaxCDAR, or aflowTypeending inLC): its CDAR file read for the invoice's number, the status code (200 to 213,fr:212taken too), the reason and the date - aStatusChangewith the file kept infile; a message without a reform status gives no status; - an invoice flow (CII, UBL, Factur-X, or a
flowTypewithInvoice): anInvoiceof the file, its syntax the flow's (CII when unknown), itsprocessingRuleandflowProfileasruleandprofile; - anything else: the flow alone.
The inbox's cursor is the platform's nextCursor, or the cursor given when there is none; more
is whether the platform gave one.
receive
> POST https://api.flow.pa.example/flow-service/v1/flows/search
> GET https://api.flow.pa.example/flow-service/v1/flows/c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b?docType=Original
> GET https://api.flow.pa.example/flow-service/v1/flows/d2b3c4d5-e6f7-4a80-9b0c-1d2e3f4a5b6c?docType=Original
invoice in FA-77.xml -
lifecycle in - F-2026-042 paid 212
cursor 'eyJjcmVhdGVkX2F0IjoiMjAyNi0xMC0wOFQwOTowMDowMFoiLCJpZCI6MTIzNH0=', more true
The CDAR message is parsed with LIBXML_NONET: no entity is fetched, nothing leaves the machine.
Inbox::invoices() and Inbox::statuses() give the invoices and the statuses alone.
Webhooks
An approved platform can call the application when a flow changes or arrives. The webhook is
created on the platform (POST /v1/webhooks, not done by this package), which answers its signing
key: the webhook_key option. With it, notify() is supported:
$notification = $gateway->notify($request->getContent(), $request->headers->all());
$notification->event; // Event::RECEIVED (a flow coming in) or Event::FLOW_UPDATED
$notification->flow; // the flow the callback carries
$notification->id; // "<flowId>@<updatedAt>": the same for a callback sent twice
The check: Afnor-Signature-Timestamp is epoch seconds, within webhook_tolerance (300 s; 0: any
age) of now; Afnor-Signature is the base64 of an HMAC-SHA-256, under the key (base64-decoded when
it is base64), of the SHA-256 fingerprint of <body>@<timestamp> - over the fingerprint's 32 bytes
or its 64 hexadecimal characters, both accepted since the OpenAPI does not say which. The body must
be a JSON flow. Each failure is an InvalidNotificationException: no key configured, a header
missing, too old, the signature not holding, not JSON.
Run with a signature computed as a platform would, then a forged one:
notify
received c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b@2026-10-26T09:00:00Z
[afnor] The signature does not hold.
Answer the platform fast (200, or 400 on an InvalidNotificationException) and do the work out of
the request: a callback announces; receive() from the kept cursor, or fetchStatus() of the flow,
gets what it announces. Deduplicate on Notification::$id. The Symfony controller:
Symfony.
Proposer une modification docs/usage-receiving.md
Declaring statuses
Some statuses only the application knows: its invoice was paid (PAID, the reform's "Encaissée",
212, mandatory), a supplier's invoice is refused (REFUSED, 210) or approved. setStatus()
declares them.
use Omnibill\Model\LifecycleStatus;
use Omnibill\Model\StatusChange;
$flow = $gateway->setStatus(new StatusChange(
LifecycleStatus::PAID,
invoiceNumber: 'F-2026-042',
at: new \DateTimeImmutable('2026-11-15'),
amount: '1250.00',
file: $cdar, // the lifecycle message, for a platform: the application's to produce
filename: 'CDV-212-F-2026-042.xml',
), $flow);
StatusChange: status, invoiceNumber, at, reason, amount (for PAID or
PARTIALLY_APPROVED, a decimal string), code (the platform's own), file and filename (the
lifecycle message, CDAR).
On an approved platform (afnor)
A platform carries a status as a lifecycle message (CDAR) of its own flow. The family does not write
one: StatusChange::$file is required (InvalidConfigException: An approved platform carries a status as a lifecycle message: give the StatusChange its CDAR file - the family does not write one.). The file is submitted as a flow (POST /v1/flows, flowSyntax CDAR, no processing rule or
profile), named filename, else CDV-<code>-<invoice number>.xml. The answer is the CDAR's
flow, PENDING, its status the one declared and its history the given flow's history plus the
change.
setStatus
> POST https://api.flow.pa.example/flow-service/v1/flows
lifecycle refused pending
The $flow argument is optional here: it only carries the history over. Keep the returned flow's
reference to follow the CDAR's own acknowledgement (fetchStatus()).
By e-mail
Nobody tells an e-mail's statuses: the application records them, and setStatus() records one in
the gateway's status store under the flow's reference - nothing is sent. The flow is required
(InvalidConfigException without: the store needs its reference). The change takes the flow's
invoice number and the current time when it has none. fetchStatus() then reads the recorded
history back.
The store is Omnibill\Email\StatusStoreInterface, given to the factory:
use Omnibill\Email\StatusStoreInterface;
use Omnibill\Model\StatusChange;
final class InvoiceStatusStore implements StatusStoreInterface
{
public function __construct(private readonly \PDO $db)
{
}
public function record(string $reference, StatusChange $change): void
{
$this->db->prepare('INSERT INTO invoice_status (reference, status, at, amount, reason) VALUES (?, ?, ?, ?, ?)')
->execute([$reference, $change->status->value, $change->at?->format(DATE_ATOM), $change->amount, $change->reason]);
}
public function history(string $reference): array
{
$rows = $this->db->prepare('SELECT * FROM invoice_status WHERE reference = ? ORDER BY id');
$rows->execute([$reference]);
return array_map(static fn (array $r) => new StatusChange(
\Omnibill\Model\LifecycleStatus::from($r['status']), at: $r['at'] ? new \DateTimeImmutable($r['at']) : null, reason: $r['reason'], amount: $r['amount'],
), $rows->fetchAll(\PDO::FETCH_ASSOC));
}
}
(run on 2026-10-26 with an SQLite database in memory behind the e-mail gateway: submit() then
setStatus(PAID, amount: '10.00') read back as sent, then paid 10.00.)
Without one, InMemoryStatusStore keeps the statuses in the process's memory: right for a test or a
script, lost at the end of a web request.
On Chorus Pro
Not supported: a supplier does not declare statuses on Chorus Pro through this API; they are read
(fetchStatus()).
The statuses
The 15 cases of LifecycleStatus, their codes and the DGFiP's names: Models.
Proposer une modification docs/usage-statuses.md
The directory and e-reporting
Both are omnibill/afnor's alone; email and chorus-pro throw NotSupportedException.
Where a company receives (lookup)
foreach ($gateway->lookup('702 042 755') as $line) {
$line->siren; // 702042755
$line->siret; // null for the legal unit's line
$line->routingCode;
$line->electronicAddress; // where to address its invoices
$line->platform; // WK: an approved platform, DFH: the public portal
$line->isEnabled(); // status Enabled (or none)
}
Supported when the gateway has a directory_url (the platform's Directory Service). The
identifier, spaces removed, must be a SIREN (9 digits) or a SIRET (14) - otherwise an
InvalidConfigException before any call. The call is POST /v1/directory-line/search with
filters.siren or filters.siret (op: strict) and include: [siren, siret, routingCode]; a 204
or an empty body is no line. Each line is an Addressing: the SIREN, SIRET and routing identifier
(read flat or nested), the addressing identifier as electronicAddress, the legal unit's business
name, platformType, directoryLineStatus (Enabled, Disabled, Upcoming) and the whole line
in data.
lookup
> POST https://api.directory.pa.example/directory-service/v1/directory-line/search
702042755 - 702042755 WK Enabled
702042755 70204275500240 702042755_70204275500240_ACHATS WK Upcoming
E-reporting (report)
use Omnibill\Model\EReport;
$flow = $gateway->report(new EReport(file_get_contents('ER-2026-10.xml'), 'ER-2026-10.xml', rule: 'B2C'));
The e-reporting file (transactions with private persons or abroad, and their payments), already
produced, is submitted as a flow: POST /v1/flows, flowSyntax FRR (the report's syntax), the
report's rule (else the gateway's rule option) as processingRule, no profile. The answer is its
flow, PENDING, kind REPORT.
report
> POST https://api.flow.pa.example/flow-service/v1/flows
report pending
Proposer une modification docs/usage-directory.md
Configuration reference
The registry
new Registry(iterable $factories, array $config);
| Key | Type | Required | |
|---|---|---|---|
<name> |
string | - | the gateway's name: get('<name>'), and in Symfony the argument's name |
<name>.factory |
string | yes | a factory's getName(): email, afnor, chorus-pro, or an application's own |
<name>.options |
map | no, default [] |
handed to the factory's create(); create($name, $overrides) puts other values over them |
The same tree is the Symfony bundle's omnibill.gateways (Symfony).
An option is read as given: null and '' count as missing for a required option, and as "not set"
for most optional ones (each factory's ?: or its own check, below).
E-mail (email)
new EmailGatewayFactory(?MailerInterface $mailer = null, ?StatusStoreInterface $store = null):
the mailer is required to build a gateway (InvalidConfigException: The "email" gateway needs a mailer: give the factory a Symfony\Component\Mailer\MailerInterface.); without a store, the
statuses live in this process's memory.
| Option | Type | Default | Required | |
|---|---|---|---|---|
from |
string | - | yes | the sender: an address, or Name <address> |
reply_to |
string | none | no | where the buyer's answers go |
bcc |
list or string | [] |
no | a copy of each invoice sent: a list, or one comma-separated string (Config::list()) |
subject |
string | Facture {number} |
no | the subject; whitespace runs become one space |
body |
string | Bonjour,\n\nVeuillez trouver ci-joint la facture {number}.\n\nCordialement,\n{seller} |
no | the text part |
Placeholders, in the subject and the body: {number}, {seller} (the seller's name), {buyer}
(the buyer's name), {total}, {currency}; an unknown value is empty. Capabilities: Factur-X, UBL,
CII; not approved; not public sector.
Approved platform (afnor)
new AfnorGatewayFactory(?HttpClientInterface $http = null).
| Option | Type | Default | Required | |
|---|---|---|---|---|
flow_url |
string | - | yes | the Flow Service's root, before /v1 (a trailing slash is dropped) |
token_url |
string | - | yes | the OAuth 2.0 token endpoint (client credentials) |
client_id |
string | - | yes | |
client_secret |
string | - | yes | |
directory_url |
string | none | no | the Directory Service's root; without it, lookup() is not supported |
scope |
string | none | no | sent with the token request when set |
organization_id |
string | none | no | the Organization-Id header: the company acted for, under a delegation |
webhook_key |
string | none | no | the webhook's signing key (base64); without it, notify() is not supported |
webhook_tolerance |
int | 300 |
no | seconds a callback may be old; 0: any age |
profile |
string | none | no | the invoices' flowProfile when the Invoice gives none (Basic, CIUS, Extended-CTC-FR); not sent for CDAR and FRR |
rule |
string | none | no | their processingRule (B2B, B2BInt, B2C, B2G...); not sent for CDAR; a report's own rule wins |
Capabilities: Factur-X, UBL, CII, CDAR, FRR; approved; public sector; 100 MB.
Chorus Pro (chorus-pro)
new ChorusProGatewayFactory(?HttpClientInterface $http = null).
| Option | Type | Default | Required | |
|---|---|---|---|---|
client_id |
string | - | yes | the PISTE application's (OAuth 2.0 client credentials, scope openid) |
client_secret |
string | - | yes | |
login |
string | - | yes | the Chorus Pro technical account (TECH_1_...@cpro.fr) |
password |
string | - | yes | sent with the login as cpro-account: base64(login:password) |
sandbox |
bool | false |
no | PISTE's sandbox and Chorus Pro's qualification; read with Config::bool() ("1", "true", "yes", "on") |
user_id |
int | none | no | idUtilisateurCourant, when the account asks for it |
syntaxes |
map | [] |
no | syntaxeFlux per syntax, over the defaults {Factur-X: IN_DP_E2_CII_FACTURX, UBL: IN_DP_E1_UBL_INVOICE, CII: IN_DP_E1_CII_16B} |
api_url |
string | https://api.piste.gouv.fr/cpro/factures (sandbox: https://sandbox-api.piste.gouv.fr/cpro/factures) |
no | |
oauth_url |
string | https://oauth.piste.gouv.fr/api/oauth/token (sandbox: https://sandbox-oauth.piste.gouv.fr/api/oauth/token) |
no |
Capabilities: Factur-X, UBL, CII; not approved; public sector; 10 MB.
Environment variables
The packages read no environment variable themselves. The names in these pages and in the harness
are conventions: PA_FLOW_URL, PA_DIRECTORY_URL, PA_TOKEN_URL, PA_CLIENT_ID,
PA_CLIENT_SECRET, PA_WEBHOOK_KEY; PISTE_CLIENT_ID, PISTE_CLIENT_SECRET, CPRO_LOGIN,
CPRO_PASSWORD, CPRO_SANDBOX; INVOICE_FROM, INVOICE_TO, MAILER_DSN; the harness's
OMNIBILL_PLUGINS and OMNIBILL_AUTOLOAD.
Proposer une modification docs/configuration.md
Errors
Every exception implements Omnibill\Exception\OmnibillException (an interface that extends
\Throwable), except the \InvalidArgumentException of Invoice::fromFile() for a file it cannot
read.
| Exception | Parent | Thrown when |
|---|---|---|
InvalidConfigException |
\LogicException |
a gateway not configured, a factory not installed, a required option missing; a factory without its mailer or its capabilities; an invoice or a status without what the gateway needs (a buyer's e-mail, a number, a CDAR file, a flow) |
NotSupportedException |
\LogicException |
the gateway does not do that request: supports() says so beforehand |
ProviderException |
\RuntimeException |
the platform refused the request or answered with an error; an action left a request unanswered |
InvalidKeyException |
ProviderException |
the platform refused the application's credentials (the token endpoint's 400, 401, 403; an API's 401 after one token renewal, or 403) |
UnreachableException |
ProviderException |
the platform did not answer: no connection, a timeout, a 5xx, a 429; the mailer could not send. Nothing was transmitted: try again |
InvalidNotificationException |
ProviderException |
a webhook's call that does not hold: no key configured, a header missing, too old, a wrong signature, not JSON |
ProviderException and its children carry provider (string: afnor, chorus-pro, email) and
providerCode (?string: the platform's own code - XP Z12-013's errorCode, Chorus Pro's
codeRetour, the HTTP status - or null); the message is [<provider>] <message>. There is no
HTTP status property: it is in the message, and in providerCode when the platform gave no code.
By gateway
afnor |
chorus-pro |
email |
|
|---|---|---|---|
| token endpoint 400, 401, 403 | InvalidKeyException |
InvalidKeyException |
- |
| 401 | the token renewed once, then InvalidKeyException |
the same | - |
| 403 | InvalidKeyException |
InvalidKeyException |
- |
| other 4xx | ProviderException (HTTP 422: <errorCode> <errorMessage> - <details>, providerCode the errorCode) |
ProviderException |
- |
codeRetour not 0 |
- | ProviderException (/v1/deposer/flux answered 20001: GDP_MSG_11.022, providerCode the code) |
- |
| 5xx, 429, no answer | UnreachableException |
UnreachableException |
the mailer's transport error: UnreachableException |
| a body that is not JSON | ProviderException |
ProviderException |
- |
Error messages carry at most 200 characters of a body that is not the platform's JSON error.
What to catch
use Omnibill\Exception\InvalidConfigException;
use Omnibill\Exception\InvalidKeyException;
use Omnibill\Exception\NotSupportedException;
use Omnibill\Exception\ProviderException;
use Omnibill\Exception\UnreachableException;
try {
$flow = $gateway->submit($invoice);
} catch (UnreachableException) {
// nothing transmitted: retry later (a delayed message)
} catch (InvalidKeyException) {
// the credentials are refused: an administrator's problem, not the invoice's
} catch (ProviderException $e) {
// the platform refused this invoice: $e->providerCode, $e->getMessage()
} catch (InvalidConfigException|NotSupportedException $e) {
// a configuration error, or the invoice lacks what this gateway needs
}
A flow REJECTED by the platform's checks is not an exception: it is the answer of a later
fetchStatus() (or a webhook), with its errors.
No secret in a failure
Answer::send() keeps the request's secrets out of what it throws. The HTTP client's message
quotes the URL called: its key parameters, a password in its user part and every secret of the
request's options are masked (api_key=***, Http\Redaction::text()). The previous exception is
a copy of the client's (Redaction::copy(): the same class and code, that message, its own
previous copied the same way), and no trace of the chain keeps its arguments, where the query,
headers and body stood: a log that writes the whole chain, arguments included
(zend.exception_ignore_args off), holds no key.
omnibill/email's UnreachableException wraps a copy of the mailer's exception the same way: an
SMTP transport's trace held the credentials it sent (AUTH PLAIN).
Proposer une modification docs/errors.md
Testing
An application that uses Omnibill tests its own code, not the platforms: the platform gateways take
the HTTP client they call with, and the e-mail gateway takes the mailer and the status store, so a
test hands them a MockHttpClient, a mailer that keeps what it is given, an InMemoryStatusStore,
and checks what was sent.
Omnibill ships no fake gateway and no conformance suite: there is no Testing\ namespace in the
package. Omnibill\Email\InMemoryStatusStore is the one test helper shipped, and it is a real
store (this process's memory).
A test case
use Omnibill\Afnor\AfnorGatewayFactory;
use Omnibill\Email\EmailGatewayFactory;
use Omnibill\Exception\UnreachableException;
use Omnibill\Model\FlowState;
use Omnibill\Model\Invoice;
use Omnibill\Model\LifecycleStatus;
use Omnibill\Model\Party;
use Omnibill\Model\Syntax;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;
final class InvoicingTest extends TestCase
{
public function testAnInvoiceGoesToThePlatformAsAFlow(): void
{
$upload = new MockResponse('{"flowId":"f-1","submittedAt":"2026-10-26T09:00:00Z"}', ['http_code' => 202]);
$http = new MockHttpClient([new MockResponse('{"access_token":"t","expires_in":3600}'), $upload]);
$platform = (new AfnorGatewayFactory($http))->create(['flow_url' => 'https://pa.example/flow-service', 'token_url' => 'https://pa.example/token', 'client_id' => 'c', 'client_secret' => 's', 'rule' => 'B2B']);
$flow = $platform->submit(new Invoice('%PDF-1.7', 'F-1.pdf', Syntax::FACTURX, 'F-1'), 'order-1');
self::assertSame('f-1', $flow->reference);
self::assertSame(FlowState::PENDING, $flow->state);
self::assertSame('https://pa.example/flow-service/v1/flows', $upload->getRequestUrl());
self::assertStringContainsString('"processingRule":"B2B","flowSyntax":"Factur-X"', $upload->getRequestOptions()['body']);
self::assertContains('Authorization: Bearer t', $upload->getRequestOptions()['headers']);
}
public function testAPlatformDownIsUnreachable(): void
{
$http = new MockHttpClient([new MockResponse('{"access_token":"t"}'), new MockResponse('', ['http_code' => 503])]);
$platform = (new AfnorGatewayFactory($http))->create(['flow_url' => 'https://pa.example/flow-service', 'token_url' => 'https://pa.example/token', 'client_id' => 'c', 'client_secret' => 's']);
$this->expectException(UnreachableException::class);
$platform->submit(new Invoice('%PDF-1.7', 'F-1.pdf', Syntax::FACTURX, 'F-1'));
}
public function testAnInvoiceByEmailIsSent(): void
{
$mailer = new class implements MailerInterface {
/** @var list<Email> */
public array $sent = [];
public function send(RawMessage $message, ?Envelope $envelope = null): void
{
$this->sent[] = $message;
}
};
$gateway = (new EmailGatewayFactory($mailer))->create(['from' => 'factures@atelier.example']);
$flow = $gateway->submit(new Invoice('%PDF-1.7', 'F-1.pdf', Syntax::FACTURX, 'F-1', buyer: new Party('Érable', email: 'compta@erable.example')));
self::assertSame(LifecycleStatus::SENT, $flow->status);
self::assertSame('Facture F-1', $mailer->sent[0]->getSubject());
self::assertSame('F-1.pdf', $mailer->sent[0]->getAttachments()[0]->getFilename());
}
}
$ vendor/bin/phpunit --bootstrap vendor/autoload.php InvoicingTest.php
PHPUnit 11.5.57 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.27
... 3 / 3 (100%)
Time: 00:00.078, Memory: 10.00 MB
OK (3 tests, 9 assertions)
(run on 2026-10-26 on the packages' checkouts, no network)
- A platform gateway asks its token first: the first answer of the
MockHttpClient's list is the token endpoint's. The token is kept for the gateway's life, so later calls need no other. MockResponse::getRequestUrl()andgetRequestOptions()give what was sent: the multipart body of a flow (itsflowInfoJSON and the file), the headers.- An HTTP 5xx, a 429 or a transport error is an
UnreachableExceptionwithout a test having to build one. - The e-mail gateway's message is a
Symfony\Component\Mime\Email: its subject, recipients, attachments andMessage-IDheader can be checked. Symfony Mailer'snull://nulltransport sends nothing, for a script.
Recorded answers
The gateways' own tests keep answers written from the platforms' published schemas, in each
package's Tests/Fixtures:
| Package | Fixtures |
|---|---|
omnibill/afnor |
token.json, flow-post-202.json, flow-metadata-ok.json, flow-metadata-error.json, flow-search.json, supplier-invoice.xml, cdar-212.xml, directory-line-search.json, error-422.json |
omnibill/chorus-pro |
token.json, deposer-flux.json, deposer-flux-trop-gros.json, rechercher-fournisseur.json, rechercher-fournisseur-vide.json |
The core's docker/harness/config/recorded.php returns a MockHttpClient callback that serves each
call from them by URL - the examples of the usage pages run on it:
$http = new MockHttpClient(require 'vendor/omnisense/omnibill/docker/harness/config/recorded.php');
(the path in a project that installed the core; the file is part of the package's sources.)
Webhooks
To test a webhook controller, sign the body as a platform does - the HMAC-SHA-256, under the
decoded key, of the SHA-256 of <body>@<timestamp>, in base64:
$key = base64_encode('the-webhook-signing-key'); // the gateway's webhook_key
$ts = (string) time();
$signature = base64_encode(hash_hmac('sha256', hash('sha256', $body.'@'.$ts, true), base64_decode($key), true));
$client->request('POST', '/invoices/webhook', server: ['HTTP_AFNOR_SIGNATURE' => $signature, 'HTTP_AFNOR_SIGNATURE_TIMESTAMP' => $ts], content: $body);
In Symfony
The bundle builds the platforms' factories on http_client: replace it with a MockHttpClient in
the test environment (framework.http_client.mock_response_factory), and the e-mail's on the
application's mailer, which framework.mailer.dsn: 'null://null' silences. A gateway's service
(omnibill.gateway.<name>) can be replaced with static::getContainer()->set(...) before it is
first used.
The package's own tests
docker compose run --rm omnibill test runs every package's tests in the harness: the
core's (the gateway and its requests, the factory and its config, the registry, the models, the
HTTP helpers, the bundle, the plain-PHP run with no framework class loaded) and each gateway's on
its fixtures.
Proposer une modification docs/testing.md
The Docker harness
docker/ runs this package with every omnibill/* gateway installed - from GitHub (branch 1.x),
or from the checkouts beside this one when OMNIBILL_PLUGINS=../.. is set in docker/.env.
cd docker && cp .env.dist .env # a mailer, a platform, PISTE, when you have them
docker compose run --rm omnibill bare
| Command | |
|---|---|
gateways |
the gateways installed and configured, what each carries and does - nothing called |
bare |
plain PHP: the registry built by hand, an invoice through each gateway, what PHP loaded |
bare --live |
the same, the e-mail sent for real through MAILER_DSN |
bare --json |
the same, whole: every class and file loaded |
test |
every package's tests |
Bare: no bundle, no container
docker/harness/bin/bare requires the autoloader and nothing else. It builds the Registry from
the gateway packages installed and sends an invoice through each - the e-mail to an in-memory
transport (or MAILER_DSN with --live), the platforms answering from their packages' recorded
answers - then lists what PHP loaded, and exits 1 if a class of a framework is among it:
$ docker compose run --rm omnibill bare
Omnibill in bare PHP: the registry built by hand, no bundle, no container; the platforms' recorded answers.
email E-mail: carries Factur-X, UBL, CII; approved platform no, public sector no; does submit fetch set
afnor Approved platform (XP Z12-013): carries Factur-X, UBL, CII, CDAR, FRR; approved platform yes, public sector yes; does submit fetch receive set lookup report
chorus-pro Chorus Pro: carries Factur-X, UBL, CII; approved platform no, public sector yes; does submit fetch
email: "Facture F-2026-042" to compta@erable.example, F-2026-042.pdf attached (application/pdf), through in memory
statuses: sent -> paid
afnor: flow 8f1d2c3b-4a5e-4f60-9a7b-1c2d3e4f5a6b pending, then accepted (deposited)
received: FA-77.xml; statuses: F-2026-042 paid
directory, SIREN 702042755: 702042755 WK Enabled; 702042755_70204275500240_ACHATS WK Upcoming
chorus-pro: deposited as CPP0021100000000000000023; accepted, made_available (MISE_A_DISPOSITION)
Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Component\Mailer, Symfony\Component\Mime, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle or bridge, Doctrine's ORM or DBAL, Twig): none
(2026-10-08) doctrine/lexer may be loaded: symfony/mime reads addresses with it; it is a
tokenizer, not Doctrine's ORM. Tests/BareTest.php runs bare --json in a process of its own and
checks the list.
The image is php:8.4-cli-alpine with Composer; the harness's packages live in the harness
volume of the omnibill-harness project.
Run on 2026-10-26
The same script, bare with no option, run on the packages' checkouts through
OMNIBILL_AUTOLOAD=vendor/autoload.php php core/docker/harness/bin/bare (PHP 8.4, no network),
printed the lines above unchanged.
The entrypoint
docker/entrypoint.sh runs docker/harness/setup.php first: it writes the harness's
composer.json and phpunit.xml in the harness volume - this core from its checkout, each
gateway from its checkout under OMNIBILL_PLUGINS when one is there, from
https://github.com/glitchr-omnisense/omnibill-<slug>.git (branch 1.x) otherwise. Then
composer install the first time, composer update when composer.json is newer than the lock,
and the command: test (PHPUnit), bare, composer, sh or php as they are, anything else the
console (gateways by default).
docker/harness/config/gateways.php says which environment variables configure each gateway
(INVOICE_FROM; PA_FLOW_URL, PA_TOKEN_URL, PA_CLIENT_ID, PA_CLIENT_SECRET, and
PA_DIRECTORY_URL, PA_WEBHOOK_KEY; PISTE_CLIENT_ID, PISTE_CLIENT_SECRET, CPRO_LOGIN,
CPRO_PASSWORD, CPRO_SANDBOX - 1 by default) and the example options bare uses instead.
Proposer une modification docs/harness.md
Writing a gateway
A gateway is a factory that extends Omnibill\GatewayFactory and one action per request it
answers. It can live in the application (an access point the application uses) or be a package of
its own, as omnibill/afnor and omnibill/chorus-pro are.
The rules
- Carry, do not produce. A gateway sends the file it is given and reads none beyond what it must to route it (a lifecycle message's status). It keeps no books.
- One action per request, only those the platform documents. A request no action supports is a
NotSupportedException, andsupports()says so beforehand; a factory may leave an action out when an option is missing (a closure of theConfigthat returns null). - Every action answers.
execute()sets the request's result (setResult()), or throws anOmnibillException; returning without a result is aProviderExceptionof the gateway. - Through the helpers. Call the platform with
Omnibill\Http\Answer::send(): no answer, a 5xx or a 429 is then anUnreachableException- nothing transmitted, try again - and the rest comes back to read. Ask an OAuth 2.0 token withOmnibill\Http\ClientCredentials,reset()it on a 401 and try once more. - Errors. Refused credentials are an
InvalidKeyException; any other refusal aProviderExceptionwith the platform's code; a callback that does not hold anInvalidNotificationException; what the invoice lacks for this gateway (a number, an e-mail) anInvalidConfigExceptionbefore any call. - A flow to keep.
submit(),report()andsetStatus()answer aFlowwhosereferenceis what the platform names it by;fetchStatus()takes a flow rebuilt from that reference and the invoice's number.
A gateway of the application's
A sketch of a Peppol access point with an API key and two routes:
<?php // peppol.php
require __DIR__.'/vendor/autoload.php';
use Omnibill\Action\ActionInterface;
use Omnibill\Action\ApiAwareInterface;
use Omnibill\Action\ApiAwareTrait;
use Omnibill\Config;
use Omnibill\Exception\ProviderException;
use Omnibill\GatewayFactory;
use Omnibill\Http\Answer;
use Omnibill\Model\Capabilities;
use Omnibill\Model\Flow;
use Omnibill\Model\FlowState;
use Omnibill\Model\Invoice;
use Omnibill\Model\Syntax;
use Omnibill\Request\FetchStatus;
use Omnibill\Request\Receive;
use Omnibill\Request\Request;
use Omnibill\Request\Submit;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/** An access point's API: a key, two routes. */
final class PeppolApi
{
public function __construct(private readonly HttpClientInterface $http, private readonly string $url, #[\SensitiveParameter] private readonly string $key)
{
}
public function call(string $method, string $path, array $options = []): array
{
$options['headers'] = ['X-Api-Key' => $this->key] + ($options['headers'] ?? []);
$answer = Answer::send($this->http, 'peppol', $method, $this->url.$path, $options);
if ($answer->status >= 400) {
throw new ProviderException('peppol', sprintf('HTTP %d', $answer->status), (string) $answer->status);
}
return $answer->json();
}
}
final class PeppolSubmitAction implements ActionInterface, ApiAwareInterface
{
/** @use ApiAwareTrait<PeppolApi> */
use ApiAwareTrait;
public function __construct()
{
$this->apiClass = PeppolApi::class;
}
public function supports(Request $request): bool
{
return $request instanceof Submit;
}
public function execute(Request $request): void
{
\assert($request instanceof Submit);
$sent = $this->api->call('POST', '/documents', ['body' => $request->invoice->content, 'headers' => ['Content-Type' => 'application/xml']]);
$request->setResult(new Flow((string) $sent['id'], state: FlowState::PENDING, trackingId: $request->trackingId, invoiceNumber: $request->invoice->number));
}
}
final class PeppolFetchStatusAction implements ActionInterface, ApiAwareInterface
{
/** @use ApiAwareTrait<PeppolApi> */
use ApiAwareTrait;
public function __construct()
{
$this->apiClass = PeppolApi::class;
}
public function supports(Request $request): bool
{
return $request instanceof FetchStatus;
}
public function execute(Request $request): void
{
\assert($request instanceof FetchStatus);
$state = $this->api->call('GET', '/documents/'.rawurlencode($request->flow->reference))['state'] ?? null;
$request->setResult($request->flow->with(state: match ($state) { 'delivered' => FlowState::ACCEPTED, 'failed' => FlowState::REJECTED, default => FlowState::PENDING }));
}
}
final class PeppolGatewayFactory extends GatewayFactory
{
public function __construct(private readonly ?HttpClientInterface $http = null)
{
}
protected function populateConfig(Config $config): void
{
$config->defaults([
'omnibill.factory_name' => 'peppol',
'omnibill.factory_title' => 'Peppol access point',
'omnibill.required_options' => ['url', 'api_key'],
'omnibill.capabilities' => new Capabilities([Syntax::UBL]),
'omnibill.api' => fn (Config $c) => new PeppolApi($this->http ?? HttpClient::create(), rtrim((string) $c['url'], '/'), (string) $c['api_key']),
'omnibill.action.submit' => new PeppolSubmitAction(),
'omnibill.action.fetch_status' => new PeppolFetchStatusAction(),
]);
}
}
$http = new MockHttpClient([new MockResponse('{"id":"doc-1"}', ['http_code' => 201]), new MockResponse('{"state":"delivered"}')]);
$gateway = (new PeppolGatewayFactory($http))->create(['url' => 'https://ap.example/api', 'api_key' => 'test']);
$flow = $gateway->submit(new Invoice('<Invoice/>', 'F-1.xml', Syntax::UBL, 'F-1'));
echo $gateway->getTitle(), ': ', $flow->reference, ' ', $flow->state->value, "\n";
echo $gateway->fetchStatus($flow)->state->value, "\n";
var_dump($gateway->supports(Receive::class));
try {
(new PeppolGatewayFactory())->create(['url' => 'https://ap.example/api']);
} catch (\Omnibill\Exception\InvalidConfigException $e) {
echo $e->getMessage(), "\n";
}
$ php peppol.php
Peppol access point: doc-1 pending
accepted
bool(false)
The "peppol" gateway needs: api_key.
(run on 2026-10-26 on a MockHttpClient, PHP 8.4; no access point was called.)
In Symfony, the factory is autoconfigured (tagged omnibill.gateway_factory) as soon as it is a
service, and named in omnibill.gateways.<name>.factory.
The factory's Config
populateConfig(Config $config) sets, with $config->defaults([...]):
| Key | |
|---|---|
omnibill.factory_name |
the factory's name, what factory: names |
omnibill.factory_title |
a readable name, getTitle() |
omnibill.required_options |
the options create() refuses to go without |
omnibill.capabilities |
a Capabilities (or a closure of the Config giving one): required |
omnibill.api |
the client the actions work with, or a closure of the Config building it at create() |
omnibill.action.<name> |
an action, or a closure of the Config giving one or null |
| every other option | its default |
Config::bool() and Config::list() read a boolean and a list as an environment variable gives
them ("1", "a,b").
A package of its own
The shipped gateways' layout, to copy: name omnibill/<platform>, namespace Omnibill\<Platform>\,
require php >=8.2, omnisense/omnibill ^1.0@dev and what the platform needs
(symfony/http-client, ext-dom); an Api class and an Action/ directory with an
AbstractAction holding the ApiAwareTrait; tests on answers written from the platform's
published schemas, in Tests/Fixtures; a README with the sources read, what they leave out, and a
"Responsibility" section. For the bundle to register the factory without the application declaring
it, its class must be in OmnibillBundle::FACTORIES; for the harness, in docker/harness/plugins.php,
config/gateways.php and config/recorded.php.
Proposer une modification docs/providers.md
Verified, and not
What the tests prove
Run on 2026-10-26 on the four packages' checkouts (the workspace's autoloader), PHP 8.4.27, PHPUnit 11.5.57, no network: 32 tests, 170 assertions, green.
- The core (11 tests): an invoice submitted, followed and paid through a gateway; what a gateway does
not do said beforehand and thrown; misconfiguration shown when the gateway is built; the
lifecycle statuses with the reform's codes; invoices, flows updated not changed, inboxes; the
token asked once and again a minute before it lapses; refused credentials and silence
(unreachable); the Symfony bundle (gateways built and injectable by name, the packages installed
registered with the application's client); the plain-PHP run with no framework class loaded and an
invoice through each gateway (
Tests/BareTest.php). omnibill/afnor(9 tests): the upload with its flowInfo, the acknowledgement as the first status, receiving from the cursor, a status as the application's CDAR, the directory, e-reporting, the webhook's signature, errors and the token renewed once, a CDAR without a reform status.omnibill/chorus-pro(5 tests): the deposit with the technical account, each syntax'ssyntaxeFluxand the sandbox's addresses, the statuses read as the reform's, errors and refusals, what Chorus Pro does not give a supplier.omnibill/email(4 tests): the invoice attached and sent, its statuses kept by hand, what an e-mail cannot do, the mailer needed and the templates.
The examples of these pages were run on 2026-10-26 on the same checkouts, the platforms answering
from the packages' fixtures through the harness's recorded.php.
What was run live, and what was not
- The e-mail: sent for real on 2026-10-08 - the harness's
bare --livethrough a local SMTP server (Mailpit, in a throwaway container): received from the sender, to the buyer, subject "Facture F-2026-042", the PDF attached, the Message-ID the flow's reference. Delivery to a real mailbox (SPF, DKIM, filters) is the application's mailer's. - An approved platform (afnor): not run against one - no account. Every answer in its fixtures is written from XP Z12-013's OpenAPI 1.3.0 and the DGFiP's Annex 2. Which routes a given platform implements is unknown until tried.
- Chorus Pro: not run against PISTE or Chorus Pro - no PISTE application, no technical account. The fixtures are written from Chorus Pro's OpenAPI and the API services annex.
- The Laravel page was written from the registry's API; it was not run in a Laravel application.
- The known defects found while writing these pages:
ext-mbstringnot required thoughmb_substr()is called (fixed: composer.json requires it); a Chorus Pro date PHP cannot read throwing PHP's\Exception(Submitting).
Responsibility
omnisense/omnibill and its omnibill/* gateways are independent, unofficial clients: none is
affiliated with, endorsed by or sponsored by the platforms or administrations it reaches. Each keeps
its own terms (accounts, approvals, credentials, the reform's obligations), named with a link in each
gateway's README. The MIT licence covers this code only; whoever runs these packages complies with
those terms and with the invoicing obligations that apply to them.