Omnistate is one PHP contract for what states and public registers know. An application asks the
same questions whatever register answers them - who is this company, does this VAT number exist,
who holds this domain or this IP range, who is this health professional, which acts and documents
name this person - and gets the same models back: Company, VatCheck, Domain, Network,
Professional, Facility, CivilRecord. Each register is a package of its own
(omnistate/annuaire-entreprises, omnistate/vies...) that turns those questions into the
service's API calls.
$omnistate->company('901 821 074'); // a SIREN, a SIRET or a French VAT number: Company
$omnistate->search('la poste', 5); // Company[], by name
$omnistate->vat('IE6388047V'); // VatCheck: valid or not, whose, a consultation number
$omnistate->domain('example.com'); // Domain: registrar, dates, statuses, name servers
$omnistate->network('193.0.6.139'); // Network: an IP range or an AS number, its holder
$omnistate->professional('10003461033'); // Professional: an RPPS number
$omnistate->facility('754567860'); // Facility: a FINESS number
$omnistate->civilRecords(new CivilQuery(familyName: 'Chirac', born: Period::year(1932))); // CivilRecord[]
The facade Omnistate\Omnistate holds the registers installed, asks the one that takes each
question, and keeps the answers in a cache when one is given (registers change slowly, and some
count their calls). Identifiers - SIREN, SIRET, EU VAT numbers, RPPS, FINESS, ISO country codes -
are checked in Omnistate\Identifier\ without asking anyone. The core needs no framework: it
requires symfony/http-client-contracts and nothing else; each register requires
symfony/http-client, a library that stands alone. A Symfony bundle is included for applications
that want the wiring done, with validation constraints and a company search field
(Symfony).
Published as omnisense/omnistate, formerly glitchr/omnistate: the package replaces the old
name, so a composer.json that still requires glitchr/omnistate resolves to this package and
never installs both. The namespace is Omnistate\; registers keep the omnistate/ vendor and the
Omnistate\<Service>\ namespace.
When to use it
- Filling and checking a customer's or a supplier's company details (a SIREN typed, the rest filled in), and checking an intra-EU VAT number before a reverse-charge invoice, with VIES's consultation number kept as the proof.
- Knowing who stands behind a domain or an IP address (registrar, dates, holder, abuse contact).
- Checking a French health professional's RPPS or a facility's FINESS against the register.
- Searching acts and documents about a person across several countries' registers, each of which says beforehand what it covers.
It is not a data broker: each register is asked live, on the application's behalf, under the service's own terms (see each register's Responsibility section). It stores nothing either, apart from the optional cache: keeping a VAT check or a company's details is the application's job.
Registers
Eight packages. The contracts are those each class implements; what each takes is what its
supports() answers (probed in the harness on 2026-10-25 against the code at
HEAD).
| Package | Class | Contract | Takes | Access |
|---|---|---|---|---|
omnistate/annuaire-entreprises |
AnnuaireEntreprises\AnnuaireEntreprises |
company | a valid SIREN or SIRET; search by anything | open, no key |
omnistate/vies |
Vies\Vies |
VAT | the 27 member states' numbers (EL for Greece) and XI (Northern Ireland) | open, no key |
omnistate/iana |
Iana\Rdap (with Iana\Bootstrap) |
Internet | domain names, IPv4 and IPv6 addresses, AS numbers | open, no key |
omnistate/annuaire-sante |
AnnuaireSante\AnnuaireSante |
professional, facility | an RPPS (or IDNPS) number; a FINESS number | free key (ANS Gravitee portal) |
omnistate/matchid |
MatchId\MatchId |
civil | FR, deaths, since 1970, a name needed | open, no key (a token optional) |
omnistate/openarchieven |
OpenArchieven\OpenArchieven |
civil | NL, BE, FR, SR; births, baptisms, marriages, deaths, burials, other; scans | open, no key |
omnistate/national-archives-uk |
NationalArchivesUk\NationalArchivesUk |
civil | GB, catalogue entries (kind other) only |
open, no key |
omnistate/nara |
Nara\Nara |
civil | US, catalogue entries (kind other) only; links to scans |
free key (10,000 calls a month) |
Five of the eight are open: no account and no key. Each register's own manual lists its endpoints, options, mapping and limits.
How this manual is organised
- Installation: requirements, a first script in plain PHP and its answer.
- Symfony and Laravel: the facade in a framework.
- Concepts: the facade, the six contracts, every model and enum.
- Usage: companies, VAT numbers, domains and networks, professionals and facilities, civil registers, identifiers.
- Configuration reference: the facade's arguments, each register's constructor, the bundle's tree.
- Errors: the three exceptions and the family's rule on them.
- Testing: testing an application that uses Omnistate; the conformance suite.
- The Docker harness: every register installed, a script that calls them.
- Writing a register, and its conformance suite.
- Verified, and not.
Propose a change docs/index.md
Installation
composer require omnisense/omnistate omnistate/annuaire-entreprises omnistate/vies omnistate/iana
composer require omnistate/matchid omnistate/openarchieven omnistate/national-archives-uk omnistate/nara # civil registers (nara: with a key)
composer require omnistate/annuaire-sante # with a key from the ANS
Requirements
- PHP 8.2 or later.
omnisense/omnistaterequiressymfony/http-client-contracts(^3.0) and nothing else.- Each register requires
omnisense/omnistateandsymfony/http-client(^6.4, ^7.0 or ^8.0), a library that stands alone and loads no framework. omnistate/ianasuggestsext-intl: with it, an international domain name (café.fr) is turned into its ASCII form (idn_to_ascii) before it is asked; without it, such a name is asked as it is written.- A cache is optional: any
Symfony\Contracts\Cache\CacheInterface(symfony/cacheprovides them).
Omnistate needs no framework. It 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 // bare.php
require __DIR__.'/vendor/autoload.php';
use Omnistate\AnnuaireEntreprises\AnnuaireEntreprises;
use Omnistate\Exception\UnavailableException;
use Omnistate\Iana\Bootstrap;
use Omnistate\Iana\Rdap;
use Omnistate\Omnistate;
use Omnistate\Vies\Vies;
use Symfony\Component\HttpClient\HttpClient;
$http = HttpClient::create();
$omnistate = new Omnistate(
companies: [new AnnuaireEntreprises($http)],
vat: [new Vies($http)],
internet: new Rdap($http, new Bootstrap($http)),
);
$company = $omnistate->company('901 821 074'); // or a SIRET, or FR53901821074
echo $company->name, ', ', $company->legalForm, ', ', $company->status->value, ', created on ', $company->createdOn->format('Y-m-d'), "\n";
echo 'head office ', $company->headOffice->identifier, ', ', $company->headOffice->address, "\n";
echo 'VAT ', $company->vatNumber, "\n\n";
$domain = $omnistate->domain('glitchr.dev');
echo $domain->name, ': ', $domain->registrar->name, ', registered on ', $domain->registeredAt->format('Y-m-d'), ', until ', $domain->expiresAt->format('Y-m-d'), "\n";
echo implode(' ', $domain->nameservers), "\n\n";
try {
$check = $omnistate->vat($company->vatNumber);
echo $check->number, $check->valid ? ' is valid: '.$check->name : ' is not valid', "\n";
} catch (UnavailableException $e) {
// A member state's service has its hours: that is not an answer about the number
echo 'VIES did not answer (', $e->getMessage(), '): ask again later', "\n";
}
$ php bare.php
GLITCH ART, SARL, active, created on 2021-07-03
head office 90182107400019, 5 RUE VINCENT SCOTTO 67400 ILLKIRCH-GRAFFENSTADEN
VAT FR53901821074
glitchr.dev: Gandi SAS, registered on 2022-05-19, until 2027-05-19
ns-1-b.gandi.net ns-210-c.gandi.net ns-238-a.gandi.net
FR53901821074 is valid: SARL GLITCH ART
(as answered on 2026-10-05, in an empty directory after the first composer require above. Run
again on 2026-10-25 in the harness, PHP 8.4: the same company and domain lines,
and France's VIES service refusing calls that day, so the last line read
VIES did not answer (VIES: MS_MAX_CONCURRENT_REQ.): ask again later.)
No key, no account: the script asks the State's Recherche d'entreprises API, IANA's RDAP bootstrap and the registry it names, and the European Commission's VIES - four calls. The pieces:
- a registry per source package (
AnnuaireEntreprises,Vies,Rdapwith itsBootstrap,AnnuaireSante,MatchId,OpenArchieven,NationalArchivesUk,Nara), each given the HTTP client to call with - the application's, aMockHttpClientin a test; Omnistate\Omnistate, built by hand from the registries:companies,vat,internet,professionals,civil, and optionally a cache (Symfony\Contracts\Cache\CacheInterface), how long answers are kept, and your own VAT number asrequester(VIES then answers a consultation number) - every argument in the configuration reference;- what it answers:
company(),search(),vat(),domain(),network(),professional(),professionals(),facility(),civilRecords(),civilRecord(), and the civil registers' coverage (civilRegistries(),civilRegistry(),civilCountries()) - see Concepts.
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 omnistate bare
(harness).
One registry
Each registry can be used without the facade: it is the contract's methods, nothing more. The facade adds what is common to all - identifiers normalized, the question routed to the register that takes it, the cache.
use Omnistate\Vies\Vies;
use Symfony\Component\HttpClient\HttpClient;
$check = (new Vies(HttpClient::create()))->check('FR53901821074', requester: 'FR...');
$check->valid; // true
$check->consultationNumber; // the proof you checked it that day
Called directly, a registry expects its identifier already normalized (Vies::check() takes
FR53901821074, not fr 53 901 821 074), and some throw an \InvalidArgumentException on an
identifier that is not one (AnnuaireSante::professional('x')): the facade does both checks
before asking.
Identifiers, without asking anyone
use Omnistate\Identifier\{Siren, Siret, VatNumber};
Siren::isValid('901821074'); // Luhn
Siren::toVatNumber('901821074'); // FR53901821074
VatNumber::normalize('gr 123456789'); // EL123456789 - each member state's shape
VatNumber::isWellFormed('FR54901821074'); // false: the key disagrees with the SIREN
These need omnisense/omnistate alone: no source package, no HTTP client. All of them:
Identifiers.
Answers kept a while
use Symfony\Component\Cache\Adapter\FilesystemAdapter; // composer require symfony/cache
$omnistate = new Omnistate([new AnnuaireEntreprises($http)], [new Vies($http)], cache: new FilesystemAdapter(), ttl: 86400);
Registers change slowly, and some count their calls. An exception is never kept: a register that did not answer is asked again next time. What is kept and under which key: Configuration.
In a framework
- Symfony:
Omnistate\Bridge\Symfony\OmnistateBundleregisters every source installed on the application'shttp_client, autowiresOmnistate\Omnistatewith a cache pool, and brings the#[Siren],#[Siret],#[VatNumber],#[Rpps],#[Finess]constraints and a company search field: see Symfony. Its components (symfony/config,symfony/dependency-injection,symfony/http-kernel,symfony/validator,symfony/form,symfony/routing,symfony/http-foundation) are not required by this package: a Symfony application has them. - Laravel: there is no bridge package; a service provider builds the facade once: Laravel.
- Any other: build
Omnistateonce, where the framework builds its services (a service provider, a container definition), as the script above does.
Errors, in short
| Exception | When |
|---|---|
UnavailableException |
the registry did not answer: down, slowed (429, with $retryAfter), a key missing or refused, a member state's VIES service closed - never an answer about the company or the number |
NotSupportedException |
no registry installed knows that kind of identifier, or takes that question |
OmnistateException |
a register refused the question itself (matchID's HTTP 400 on a date it cannot read...) |
Unknown is null (a company, a domain) or an empty list; a VAT number that does not exist is a
VatCheck whose valid is false. The whole rule: Errors.
Propose a change docs/installation.md
Symfony
Omnistate runs without a framework (installation); in a Symfony application its
bundle does the wiring. Its components - symfony/config, symfony/dependency-injection,
symfony/http-kernel, and symfony/validator, symfony/form, symfony/routing,
symfony/http-foundation for the constraints and the company search - are not required by
omnisense/omnistate: the application has them, and nothing of them is loaded outside Symfony.
Registering the bundle
There is no Flex recipe: add the bundle by hand.
// config/bundles.php
return [
// ...
Omnistate\Bridge\Symfony\OmnistateBundle::class => ['all' => true],
];
Configuration
# config/packages/omnistate.yaml
omnistate:
cache: cache.app # null: every call asks the registry
ttl: 86400
timeout: 10
requester: FR53901821074 # your VAT number: VIES answers a consultation number
matchid:
token: ~ # raises matchID's quota
nara:
api_key: '%env(NARA_API_KEY)%' # the Catalog API key, 10,000 calls a month: the cache (ttl) spares it
annuaire_sante:
api_key: '%env(ESANTE_API_KEY)%' # the Gravitee key; empty: every call answers UnavailableException
# .env.local, or bin/console secrets:set
NARA_API_KEY=...
ESANTE_API_KEY=...
The whole tree, from OmnistateBundle::configure() (dumped with Symfony's reference dumper in the
harness on 2026-10-25):
| Key | Type | Default | |
|---|---|---|---|
omnistate.cache |
string or null | cache.app |
a cache pool's service id; null (~): no cache, every call asks the register |
omnistate.ttl |
integer, at least 0 | 86400 |
seconds an answer is kept in that pool |
omnistate.timeout |
float | 10 |
seconds each register (and IANA's bootstrap) has to answer, handed to every register's constructor |
omnistate.requester |
string or null | null |
your own VAT number, sent with every VAT check that names no other: VIES then answers a consultation number |
omnistate.matchid.token |
string or null | null |
a matchID API token (Authorization: Bearer); optional, a higher quota |
omnistate.nara.api_key |
string or null | null |
the Catalog API key (x-api-key); without one, every NARA call is an UnavailableException |
omnistate.annuaire_sante.api_key |
string or null | null |
the Gravitee key (ESANTE-API-KEY); without one, every Annuaire Santé call is an UnavailableException, before any request |
omnistate.annuaire_sante.url |
string | https://gateway.api.esante.gouv.fr/fhir/v2 |
the FHIR API's base address |
Nothing else is configurable through the bundle: OpenArchieven's label language (lang, en)
and the other registers' base addresses keep their defaults. An application that needs another
value defines the register's service itself (An application's own
registers).
Services
The bundle registers each register whose class exists - the packages installed - on the
application's http_client service, with omnistate.timeout:
| Service id | Built with | Tag |
|---|---|---|
Omnistate\AnnuaireEntreprises\AnnuaireEntreprises |
http_client, timeout |
omnistate.company_registry |
Omnistate\Vies\Vies |
http_client, timeout |
omnistate.vat_registry |
Omnistate\AnnuaireSante\AnnuaireSante |
http_client, annuaire_sante.api_key, timeout, annuaire_sante.url |
omnistate.professional_registry |
Omnistate\MatchId\MatchId |
http_client, matchid.token, timeout |
omnistate.civil_registry |
Omnistate\OpenArchieven\OpenArchieven |
http_client, timeout |
omnistate.civil_registry |
Omnistate\NationalArchivesUk\NationalArchivesUk |
http_client, timeout |
omnistate.civil_registry |
Omnistate\Nara\Nara |
http_client, nara.api_key, timeout |
omnistate.civil_registry |
Omnistate\Iana\Bootstrap |
http_client, the cache pool (or null), timeout |
- |
Omnistate\Iana\Rdap |
http_client, Bootstrap, timeout; aliased as Omnistate\Registry\InternetRegistryInterface |
- |
And the bundle's own:
| Service id | Public | |
|---|---|---|
Omnistate\Omnistate |
yes | the facade: the tagged registers by kind, Rdap as the Internet registry when omnistate/iana is installed, the cache pool, ttl, requester. Autowired by its class. |
Omnistate\Bridge\Symfony\CompanySearch |
yes | the company search's rows (Company search) |
Omnistate\Bridge\Symfony\Controller\CompanySearchController |
yes | its endpoint, tagged controller.service_arguments |
Omnistate\Bridge\Symfony\Form\CompanySearchType |
no | the form field, tagged form.type, given the router when there is one; registered only when symfony/form is installed |
Omnistate\Bridge\Symfony\Validator\VatNumberValidator |
no | tagged validator.constraint_validator, given the facade (for checkExistence) |
use Omnistate\Omnistate;
final class SupplierController
{
public function __construct(private readonly Omnistate $omnistate)
{
}
}
Nothing is asked when the container compiles: a register is called the first time a question
needs it. Omnistate\Iana\Bootstrap keeps IANA's bootstrap files a day in the same cache pool
(omnistate.iana.bootstrap.dns, .ipv4, .ipv6, .asn).
When Twig is installed, the bundle prepends its form theme
@Omnistate/form/company_search.html.twig to twig.form_themes: the company search field renders
with its suggestions list and script in every form.
The bundle registers no command, no event, no Twig function, and no route of its own: the company search's route exists only once the application imports it.
An application's own registers
A class of the application that implements one of these contracts is autoconfigured - tagged -
and the facade asks it too. The facade asks the registers of a kind in the order of the tagged
iterator (Symfony's tagged_iterator(): by the tag's priority, then by definition), and for a
lookup the first whose supports() accepts the identifier answers. The bundle sets no priority on
the packages' registers.
| Contract | Tag | Asked by |
|---|---|---|
Omnistate\Registry\CompanyRegistryInterface |
omnistate.company_registry |
company(), search() |
Omnistate\Registry\VatRegistryInterface |
omnistate.vat_registry |
vat() |
Omnistate\Registry\ProfessionalRegistryInterface |
omnistate.professional_registry |
professional(), professionals(), and facility() when it implements FacilityRegistryInterface too |
Omnistate\Registry\CivilRegistryInterface |
omnistate.civil_registry |
civilRecords(), civilRecord(), civilRegistries()... |
Two contracts are not autoconfigured: FacilityRegistryInterface alone (the facade asks facilities
only of its professional registers, so a facility register must implement
ProfessionalRegistryInterface too), and InternetRegistryInterface (the facade takes one
Internet registry, Rdap when omnistate/iana is installed; another one is given by redefining
the Omnistate\Omnistate service's third argument).
A register of a package with another configuration than the bundle's - OpenArchieven labelled
in Dutch, say - is defined by the application under its class name; the application's definition
wins over the bundle's (Symfony keeps the application's definitions over an extension's):
# config/services.yaml
services:
Omnistate\OpenArchieven\OpenArchieven:
arguments: ['@http_client', 10, 'nl']
tags: [omnistate.civil_registry]
Constraints
Five constraints in Omnistate\Bridge\Symfony\Validator, each an attribute on a property or a
method, repeatable, with its own message (and groups, payload). A null or '' value
passes: combine them with #[NotBlank] when the field is required.
| Constraint | Checks | Default message |
|---|---|---|
#[Siren] |
nine digits, the Luhn key right (Identifier\Siren::isValid()) |
This is not a valid SIREN. |
#[Siret] |
fourteen digits, the key right, La Poste's rule too (Identifier\Siret::isValid()) |
This is not a valid SIRET. |
#[VatNumber] |
the member state's shape, and for France the key agreeing with the SIREN (Identifier\VatNumber::isWellFormed()) |
This is not a valid EU VAT number. |
#[VatNumber(checkExistence: true)] |
the same, then VIES through the facade: a number VIES says does not exist gets unknownMessage |
No EU member state knows this VAT number. |
#[Rpps] |
eleven digits (or the IDNPS, 8 + RPPS), the Luhn key right | This is not a valid RPPS number. |
#[Finess] |
nine characters (2A / 2B for Corsica), the Luhn key right | This is not a valid FINESS number. |
use Omnistate\Bridge\Symfony\Validator as Omnistate;
final class SupplierForm
{
#[Omnistate\Siren]
public ?string $siren = null;
#[Omnistate\Siret(message: 'Ce SIRET est mal écrit.')]
public ?string $siret = null;
#[Omnistate\VatNumber(checkExistence: true)] // its shape, then VIES
public ?string $vatNumber = null;
#[Omnistate\Rpps]
public ?string $rpps = null;
#[Omnistate\Finess]
public ?string $finess = null;
}
With checkExistence, VIES not answering is not a reason to refuse the number: on any
OmnistateException (an UnavailableException, or no VAT register installed) the value passes.
Without the bundle (a validator built by hand) VatNumberValidator has no facade, and
checkExistence checks the shape only. The {{ value }} parameter is set on every violation.
#[VatNumber] reads the value through VatNumber::normalize(), which turns a bare SIREN or SIRET
into its French VAT number: 901821074 in a VAT number field passes (and is checked with VIES as
FR53901821074). Store the normalized number, not what was typed, when that matters.
Company search
CompanySearchType is a search field that suggests companies as one types and, on a pick, fills
the sibling fields named in fill:
use Omnistate\Bridge\Symfony\Form\CompanySearchType;
$builder
->add('company', CompanySearchType::class, [
'fill' => ['vatNumber' => 'vatNumber', 'companyName' => 'name', 'address' => 'address', 'siret' => 'siret'],
])
->add('companyName')
->add('siret')
->add('vatNumber')
->add('address');
| Option | Type | Default | |
|---|---|---|---|
fill |
array | [] |
sibling field name => result key; siblings the form does not have are left out |
endpoint |
string or null | null |
the address asked; null: the route omnistate_company_search, or /omnistate/company/search without a router |
min_length |
int | 3 (CompanySearch::MIN_LENGTH) |
characters typed before the first request |
limit |
int | 10 |
suggestions asked for |
mapped |
bool | false |
the field only helps fill the others |
required |
bool | false |
Its parent is SearchType, its block prefix omnistate_company_search. The view's attr carries
autocomplete="off", data-omnistate-company-search (the endpoint), data-omnistate-fill (a JSON
object: the siblings' element ids, <form id>_<field>, to result keys),
data-omnistate-min-length and data-omnistate-limit. The form theme's script, bound once on the
document, waits 300 ms after the last key, asks the endpoint with q and limit, lists the rows
(a closed company dimmed, marked (closed)), and on a pick writes the name in the field and each
key's value in its sibling, dispatching input and change on it; Escape closes the list.
The result keys, from CompanySearch::row():
| Key | Value |
|---|---|
siren |
the company's identifier |
siret |
the head office's identifier, or null |
name |
its legal name, else its name |
vatNumber |
the French VAT number derived from the SIREN (for a French company with a valid SIREN), else null - VIES still has the last word on it |
address, postalCode, city |
the head office's address, one line, and its parts |
activity |
the activity's code and label (62.02A) |
legalForm |
its short name, else its label, else its code (SARL) |
active |
true unless the company is closed |
country |
ISO 3166-1 alpha-2 |
The field asks CompanySearchController, GET /omnistate/company/search?q=...&limit=... (route
name omnistate_company_search). Route it by importing the bridge's controllers:
# config/routes/omnistate.yaml
omnistate:
resource: '../../vendor/omnisense/omnistate/Bridge/Symfony/Controller/'
type: attribute
or through a subclass of CompanySearchController in a directory already imported. It answers
{"results": [...]} (200, Cache-Control: private, max-age=3600), at most 25 rows
(CompanySearch::MAX_RESULTS), none for a query shorter than three characters (no register
asked); when the registers do not answer (any OmnistateException), {"results": [], "error": "unavailable"} with a 503. Rows come from the facade's search(): the first company register
that finds anything, from the cache when it was asked before.
Protect that route. The controller checks nothing itself: left open, anyone can use your server as a relay to the registries and spend their rate limits (Recherche d'entreprises: about 7 calls a second). Put it behind your back office's access control, or give your subclass an
#[IsGranted].
Propose a change docs/symfony.md
Laravel
omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnistate provider, and
there is no other Laravel package for Omnistate. None is needed: the facade is plain PHP, and a
service provider of the application builds it once, from a config file, with a
symfony/http-client client (every register requires that package; Laravel's own HTTP client is
Guzzle's, which the registers do not take).
// config/omnistate.php
return [
'ttl' => 86400,
'timeout' => 10,
'requester' => env('OMNISTATE_REQUESTER'), // your VAT number: VIES answers a consultation number
'annuaire_sante_key' => env('ESANTE_API_KEY'), // omnistate/annuaire-sante
'matchid_token' => env('MATCHID_TOKEN'), // omnistate/matchid, optional
'nara_key' => env('NARA_API_KEY'), // omnistate/nara
];
// app/Providers/OmnistateServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Omnistate\AnnuaireEntreprises\AnnuaireEntreprises;
use Omnistate\AnnuaireSante\AnnuaireSante;
use Omnistate\Iana\Bootstrap;
use Omnistate\Iana\Rdap;
use Omnistate\MatchId\MatchId;
use Omnistate\Omnistate;
use Omnistate\OpenArchieven\OpenArchieven;
use Omnistate\Vies\Vies;
use Symfony\Component\Cache\Adapter\Psr16Adapter;
use Symfony\Component\HttpClient\HttpClient;
final class OmnistateServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Omnistate::class, function ($app): Omnistate {
$http = HttpClient::create();
$timeout = (float) config('omnistate.timeout');
// Laravel's cache store is a PSR-16 cache: symfony/cache's Psr16Adapter makes it the
// Symfony\Contracts\Cache\CacheInterface the facade and IANA's bootstrap take.
$cache = new Psr16Adapter($app['cache.store'], 'omnistate');
return new Omnistate(
companies: [new AnnuaireEntreprises($http, $timeout)],
vat: [new Vies($http, $timeout)],
internet: new Rdap($http, new Bootstrap($http, $cache, $timeout), $timeout),
cache: $cache,
ttl: (int) config('omnistate.ttl'),
requester: config('omnistate.requester'),
professionals: [new AnnuaireSante($http, config('omnistate.annuaire_sante_key'), $timeout)],
civil: [
new MatchId($http, config('omnistate.matchid_token'), $timeout),
new OpenArchieven($http, $timeout),
],
);
});
}
}
Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject the facade
where it is needed:
use Omnistate\Omnistate;
final class SupplierController
{
public function show(Omnistate $omnistate, string $siren)
{
$company = $omnistate->company($siren);
// ...
}
}
List in the provider every register the application uses: unlike the Symfony bundle, a
hand-built facade knows only the registers it is given. symfony/cache is an extra requirement
of this example (composer require symfony/cache); without a cache, leave cache out and every
call asks the register. Configuration caching (php artisan config:cache) keeps working: the
options are plain values read through config().
The Symfony bridge's pieces - the validation constraints, the company search field and its
endpoint - have no Laravel counterpart. The identifier rules they use are plain PHP
(Omnistate\Identifier\Siren::isValid()...) and fit a Laravel validation rule written by hand:
use Omnistate\Identifier\VatNumber;
$request->validate([
'vat_number' => ['nullable', function (string $attribute, mixed $value, \Closure $fail) {
if (!VatNumber::isWellFormed((string) $value)) {
$fail('This is not a valid EU VAT number.');
}
}],
]);
This page was written from the facade's and the registers' constructors; it was not run in a
Laravel application. The provider's construction was run in plain PHP in the
harness on 2026-10-25, with a symfony/cache ArrayAdapter in place of the
Psr16Adapter (the harness has no psr/simple-cache, which Laravel brings): the facade built,
a company asked twice, one request made.
Propose a change docs/laravel.md
Concepts
The facade
Omnistate\Omnistate (final) holds the registers installed, by kind, and answers the family's
questions. Its arguments are in the configuration reference; what
each method does before it asks:
| Method | Returns | Asks | Before asking |
|---|---|---|---|
company(string $identifier): ?Company |
the company, null when the register does not know it |
the first company register whose supports() takes the identifier |
spaces, dots and dashes removed, upper case; a French VAT number (FR + key + a valid SIREN) becomes its SIREN; then Siret::normalize(), else Siren::normalize() |
search(string $query, int $limit = 10): array |
Company[] |
each company register in turn, until one finds any | nothing: the query is passed as it is |
vat(string $number, ?string $requester = null): VatCheck |
the check | the first VAT register whose supports() takes the normalized number |
VatNumber::normalize(); a number with no member state's shape, or a French one whose key disagrees with its SIREN, is answered at once: a VatCheck with valid false and source format, no register asked. The requester (the argument, else the facade's) is normalized too |
professional(string $identifier): ?Professional |
the professional, or null |
the first professional register whose supports() takes it |
spaces, dots and dashes removed; an IDNPS (8 + RPPS) becomes its RPPS |
professionals(string $name, ?string $postcode = null, int $limit = 10): array |
Professional[] |
each professional register in turn, until one finds any | nothing |
facility(string $identifier): ?Facility |
the facility, or null |
the first professional register that implements FacilityRegistryInterface and whose supportsFacility() takes it |
Finess::normalize(), else spaces, dots and dashes removed and upper case |
civilRecords(CivilQuery $query, ?string $registry = null): array |
CivilRecord[] |
every civil register whose supports() takes the query (only the one named $registry when given), one after the other in the order given |
nothing: the query object carries its own normalization |
civilRecord(string $registry, string $identifier): ?CivilRecord |
the record, or null |
the civil register of that name, its find() |
nothing |
civilRegistries(?string $country = null, ?CivilRecordKind $kind = null): array |
CivilRegistryInterface[] |
no one: the registers' own declarations | the country upper-cased and trimmed |
civilRegistry(string $name): ?CivilRegistryInterface |
the register of that name, null when it is not installed |
no one | - |
civilCountries(): array |
ISO 3166-1 alpha-2 codes some civil register covers, sorted | no one | - |
domain(string $name): ?Domain |
the domain, or null |
the Internet registry | trimmed, lower case, the trailing dot removed |
network(string $address): ?Network |
the IP range or the autonomous system, or null |
the Internet registry | trimmed, upper case (as3333 is AS3333) |
What it throws:
NotSupportedExceptionwhen no register installed takes the question: no company register supports that identifier (No company registry installed knows "X".), no VAT register answers for that country, no professional or facility register knows that identifier, no civil register takes the query (No civil registry installed takes that question., orThe civil registry "x" is not installed, or does not take that question.when one was named),civilRecord()of a register not installed,domain()/network()with no Internet registry (No Internet registry installed: add omnistate/iana.).search()andprofessionals()never throw it: with no register installed they answer[].- Whatever the register throws:
UnavailableExceptionwhen it did not answer, sometimes anOmnistateExceptionwhen it refused the question (Errors).
civilRecords() without a register name asks every register the question is for: one that does
not answer throws, and the others' answers are lost with it. A screen that must keep what the
others found asks them one by one (Civil registers).
Answers are kept in the cache when one is given: every method that asks a register, under a key
made of the method, the register's name and the normalized question
(Configuration). civilRegistries(), civilRegistry() and
civilCountries() ask no one and are not cached.
The six contracts
A register implements one or more interfaces of Omnistate\Registry\. Each has name(): string,
a short name in kebab case (annuaire-entreprises) that is the $source of every model it
returns.
CompanyRegistryInterface
A register of companies.
| Method | |
|---|---|
supports(string $identifier): bool |
whether it can answer for that identifier (a SIREN, a SIRET...) - checked without a request |
company(string $identifier): ?Company |
the company, with the establishment asked for among its establishments when the identifier is one; null when the register does not know it |
search(string $query, int $limit = 10): array |
list<Company> by name, or anything the register searches on |
VatRegistryInterface
A register of VAT numbers.
| Method | |
|---|---|
supports(string $number): bool |
whether it answers for that (normalized) number's country |
check(string $number, ?string $requester = null): VatCheck |
whether the number exists, and whose; with $requester (your own VAT number) the register may give a consultation number |
A check that cannot be made is an UnavailableException, never a VatCheck saying "not valid".
ProfessionalRegistryInterface
A register of regulated professionals.
| Method | |
|---|---|
supports(string $identifier): bool |
whether it can answer for that identifier (an RPPS number...) |
professional(string $identifier): ?Professional |
the professional, null when unknown |
search(string $name, ?string $postcode = null, int $limit = 10): array |
list<Professional> by name, only those practising in that postcode when one is given |
FacilityRegistryInterface
A register of health and social facilities. The facade asks it only of the professional registers that implement it too.
| Method | |
|---|---|
supportsFacility(string $identifier): bool |
whether it can answer for that identifier (a FINESS number...) |
facility(string $identifier): ?Facility |
the facility, null when unknown |
CivilRegistryInterface
A register of acts and documents about people. It says what it covers, so a screen can tell before asking.
| Method | |
|---|---|
countries(): array |
list<string>, ISO 3166-1 alpha-2 codes of the countries whose records it holds |
kinds(): array |
list<CivilRecordKind> it holds |
period(): ?Period |
the years it covers when it has bounds (France's deaths file: Period::since(1970)), null when there is no bound to tell |
hasImages(): bool |
whether it gives pictures of the acts (CivilRecord::$images) for some records |
supports(CivilQuery $query): bool |
whether the question is one for it: its country, one of its kinds, something it can search on |
search(CivilQuery $query): array |
list<CivilRecord>, the likeliest first; none is [] |
find(string $identifier): ?CivilRecord |
a record by the identifier a search gave, with everything the register tells; null when unknown |
InternetRegistryInterface
The Internet's registries.
| Method | |
|---|---|
domain(string $name): ?Domain |
a domain name's registration, null when no one registered it |
network(string $address): ?Network |
an IP address's range, or an AS number's (AS3215, 3215) |
Every method that asks may throw UnavailableException; none returns null or [] for a
register that did not answer (the family's rule).
Models
All models are final readonly classes in Omnistate\Model, built with named arguments.
Whatever a register cannot tell is null or an empty list. Countries are ISO 3166-1 alpha-2,
dates \DateTimeImmutable unless they may be partial (PartialDate), amounts in the currency's
major unit. The models that carry $raw keep the register's whole answer for what they leave out.
Company
| Property | Type | Default | |
|---|---|---|---|
identifier |
string |
required | the register's identifier (France: the SIREN) |
name |
string |
required | the name in use |
status |
CompanyStatus |
required | active or closed |
country |
string |
required | ISO 3166-1 alpha-2 |
source |
string |
required | the register's name: annuaire-entreprises |
legalName |
?string |
null |
the registered name (raison sociale) |
acronym |
?string |
null |
|
legalForm |
?LegalForm |
null |
|
activity |
?Activity |
null |
the main activity |
category |
?string |
null |
the size category (France: PME, ETI, GE) |
headcount |
?Headcount |
null |
|
createdOn, closedOn |
?\DateTimeImmutable |
null |
|
headOffice |
?Establishment |
null |
|
establishments |
list<Establishment> |
[] |
those the answer included (all, or the ones that matched), the head office not repeated |
establishmentCount, openEstablishmentCount |
?int |
null |
how many the register counts, open or not |
managers |
list<Manager> |
[] |
|
finances |
list<FinancialYear> |
[] |
newest first |
labels |
list<string> |
[] |
what it is recognized as: ess, rge, qualiopi... (the list) |
collectiveAgreements |
list<string> |
[] |
the collective agreements it applies (France: IDCC codes) |
vatNumber |
?string |
null |
its VAT number (France: derived from the SIREN, VIES not asked) |
updatedAt |
?\DateTimeImmutable |
null |
when the register last changed it |
raw |
array |
[] |
isActive(): bool is status === CompanyStatus::ACTIVE. establishment(string $identifier): ?Establishment finds the head office or one of establishments by its identifier.
Establishment
One of a company's places of business (France: a SIRET).
| Property | Type | Default | |
|---|---|---|---|
identifier |
string |
required | the SIRET |
headOffice |
bool |
required | whether it is the head office |
status |
CompanyStatus |
required | |
address |
?Address |
null |
|
name |
?string |
null |
its trade name |
signs |
list<string> |
[] |
the signs it trades under |
activity |
?Activity |
null |
|
headcount |
?Headcount |
null |
|
createdOn, closedOn |
?\DateTimeImmutable |
null |
isActive(): bool.
Address
| Property | Type | Default | |
|---|---|---|---|
street |
?string |
null |
number, type and name of the street |
postalCode |
?string |
null |
|
city |
?string |
null |
|
country |
?string |
null |
ISO 3166-1 alpha-2 |
full |
?string |
null |
the register's own one-line form |
latitude, longitude |
?float |
null |
(string) $address is full, else street, postal code and city joined by spaces.
Activity, LegalForm, Headcount, Manager, FinancialYear
| Class | Properties | __toString() |
|---|---|---|
Activity |
code (62.02A), classification (NAF rev. 2), ?label, ?section (the NAF's letter: J) |
the code, and the label when there is one |
LegalForm |
code (INSEE's 5499), ?label, ?short (SARL) |
the short name, else the label, else the code |
Headcount |
?min, ?max (null: no upper bound), ?year (when it was counted), ?code (INSEE's: 12 for 20 to 49) |
'' when no minimum, 10000+, 5, 20-49 |
Manager |
name, ?role (Gérant), person (bool, default true; false for a company), ?firstNames, ?identifier (a managing company's SIREN), ?birthYear, ?nationality |
first names and name |
FinancialYear |
year, ?revenue, ?netIncome, currency (default EUR) |
- |
Headcount::isEmployer() is true when max is set and above 0.
VatCheck
| Property | Type | Default | |
|---|---|---|---|
number |
string |
required | the number checked, normalized (EL... for Greece) |
valid |
bool |
required | whether it exists |
checkedAt |
\DateTimeImmutable |
required | the register's date of the check (VIES's requestDate), else now |
source |
string |
required | vies, or format when the facade refused the number for its shape without asking |
name, address |
?string |
null |
whose it is, when the member state tells (address lines joined by , ) |
consultationNumber |
?string |
null |
the proof of the check, when it was made with your own VAT number: keep it with a reverse-charge invoice |
country(): string is the number's first two letters.
Domain and Network
Domain property |
Type | |
|---|---|---|
name |
string |
lower case, ASCII form for an international name |
source |
string |
iana |
handle |
?string |
the registry's handle |
statuses |
list<string> |
RDAP statuses: active, client transfer prohibited... |
registrar |
?Contact |
|
registrant |
?Contact |
null when the registry does not publish it (most, since the GDPR) |
registeredAt, expiresAt, changedAt |
?\DateTimeImmutable |
the registration, expiration and last changed events |
nameservers |
list<string> |
lower case |
dnssec |
bool |
whether the delegation is signed (default false) |
server |
?string |
the RDAP server that answered |
raw |
array |
Network property |
Type | |
|---|---|---|
handle |
string |
the registry's handle (AS3333, 193.0.0.0 - 193.0.7.255) |
source |
string |
iana |
name |
?string |
RIPE-NCC |
type |
?string |
ALLOCATED PA, ASSIGNED PA, DIRECT ALLOCATION... |
country |
?string |
|
startAddress, endAddress |
?string |
an IP range's first and last address |
startAutnum, endAutnum |
?int |
an autonomous system's numbers |
registrant, abuse |
?Contact |
the holder, the abuse contact |
registeredAt, changedAt |
?\DateTimeImmutable |
|
server |
?string |
|
raw |
array |
Contact: ?name, ?organization, ?email, ?country, ?handle, ?ianaId (a registrar's
IANA ID), ?url. (string) $contact is the organization, else the name, else the handle.
Professional, Workplace, Facility, Qualification
Professional property |
Type | Default | |
|---|---|---|---|
identifier |
string |
required | France: the RPPS number |
familyName |
string |
required | |
source |
string |
required | annuaire-sante |
givenName |
?string |
null |
|
prefix |
?string |
null |
M, MME, DR... as the register writes it |
profession |
?Qualification |
null |
|
specialties |
list<Qualification> |
[] |
specialties and know-how |
diplomas |
list<Qualification> |
[] |
|
workplaces |
list<Workplace> |
[] |
active ones first |
phones, emails |
list<string> |
[] |
|
secureEmails |
list<string> |
[] |
secure messaging (France: MSSanté) |
languages |
list<string> |
[] |
ISO 639-1 |
active |
bool |
true |
|
updatedAt |
?\DateTimeImmutable |
null |
|
raw |
array |
[] |
name() (and (string)) is the given name and the family name.
Workplace: ?name, ?address (Address), ?facility (the facility's identifier: FINESS, or
the register's structure id), ?mode (liberal, salaried, volunteer, or the register's own
code), ?role, phones, secureEmails, active (default true). (string) is the name and
the address.
Facility: identifier (FINESS), name, source, ?kind (legal: the entity, site: one of
its places), types (list<Qualification>), ?address, phones, emails, secureEmails,
identifiers (every identifier the register gives it), active (default true), ?updatedAt,
raw. (string) is the name.
Qualification: code, system (the nomenclature: a URL or a short name,
TRE_G15-ProfessionSante), ?label. (string) is the label, else the code.
Civil records: CivilQuery, CivilRecord, CivilPerson, Archive, Place
CivilQuery is what is asked of a civil register:
| Argument | Type | Default | |
|---|---|---|---|
familyName, givenName |
?string |
null |
|
born, died |
?Period |
null |
when the person was born, died |
dated |
?Period |
null |
when the event recorded took place, whatever it is |
place |
?string |
null |
the town of the event |
country |
?string |
null |
the country whose registers are asked; trimmed, upper case, '' is null |
kinds |
list<CivilRecordKind> |
[] |
the kinds wanted; empty: any |
sex |
?Sex |
null |
|
limit |
int |
20 |
kept between 1 and 100 |
page |
int |
1 |
at least 1 |
Its methods: name() (given and family name), hasName(), wants(CivilRecordKind) (true when
kinds is empty or holds it), wantsAny(...$kinds), inCountries(...$countries) (true when no
country is asked or it is one of them), key() (the same question gives the same key: the cache
uses it). A register uses what it can search on and ignores the rest; each register's manual says
which fields it reads.
CivilRecord:
| Property | Type | Default | |
|---|---|---|---|
identifier |
string |
required | what civilRecord() takes to find it again in its register |
kind |
CivilRecordKind |
required | |
source |
string |
required | the register's name |
date |
?PartialDate |
null |
of the event, not of the paper |
place |
?Place |
null |
|
persons |
list<CivilPerson> |
[] |
the one the record is about first |
archive |
?Archive |
null |
who keeps the original |
url |
?string |
null |
the record's page at the register |
images |
list<string> |
[] |
scans of the act, when the register publishes them |
title |
?string |
null |
what the register calls it |
number |
?string |
null |
the act's number in its register |
description |
?string |
null |
|
period |
?Period |
null |
when the register dates it by a span rather than a day |
raw |
array |
[] |
principal(): ?CivilPerson is the first person; hasImages(): bool.
CivilPerson: familyName (default ''), givenNames (list<string>), sex (default
Sex::UNKNOWN), ?role (as the register writes it: Child, Vader, Deceased...),
?birthDate, ?birthPlace, ?deathDate, ?deathPlace, ?age (at the event), ?profession,
?fullName (when the register gives the name in one piece only). name() is the given names and
the family name, else fullName; givenName() the first given name.
Archive: name (Brabants Historisch Informatie Centrum), ?code (the register's code for
it), ?reference (the call number), ?url (the record at the archive). (string) is the name
and the reference.
Place: ?name (the town as written), ?code (France: the INSEE commune code), ?postcode,
?region (a département's code, a province), ?country (ISO alpha-2), ?countryName (as
written, when no code is known), ?latitude, ?longitude. (string) is the name and the
country.
PartialDate and Period
PartialDate is a date as a register knows it: ?year, ?month (1 to 12), ?day. What is not
known is null, never a made-up 1st of January.
| Method | |
|---|---|
parse(?string $value): ?self |
reads 1932-11-29, 19321129, 29/11/1932, 29.11.1932, 1932-11, 11/1932, 1932, and zeros as unknown parts (19320000); null when no year can be read |
of(?int $year, ?int $month, ?int $day): ?self |
zeros and out-of-range parts become unknown; no year, null; a day without a month is dropped |
fromDate(\DateTimeInterface $date): self |
a whole date |
isComplete(): bool |
year, month and day known |
toDate(): ?\DateTimeImmutable |
the day itself when it is whole and exists, else null |
earliest(), latest() |
the first and the last day it can be (1932 is 1932-01-01 to 1932-12-31) |
(string) |
ISO 8601 as far as it is known: 1932-11-29, 1932-11, 1932 |
Period is a span between two PartialDates, either open: ?from, ?to.
| Method | |
|---|---|
year(int $year) |
one year, at both ends |
years(?int $from, ?int $to) |
from a year to a year, either null |
since(int $year) |
open at the end |
around(int $year, int $margin = 2) |
a year give or take $margin |
day(PartialDate $date) |
one date at both ends |
isDay(), isYear() |
|
contains(PartialDate $date): bool |
whether the date can fall in it |
(string) |
1932, 1970/…, 1850/1860 |
Enums
| Enum | Cases |
|---|---|
CompanyStatus (string) |
ACTIVE (active), CLOSED (closed) |
CivilRecordKind (string) |
BIRTH (birth), BAPTISM (baptism), MARRIAGE (marriage), DEATH (death), BURIAL (burial), OTHER (other: a population register, a notarial deed, a catalogue entry of an archive) |
Sex (string) |
MALE (M), FEMALE (F), UNKNOWN (U) |
CivilRecordKind::gedcom() is the GEDCOM tag of the event: BIRT, CHR, MARR, DEAT, BURI,
null for OTHER. Sex::fromAny(mixed) reads what registers write: M, male, man, h,
homme, masculin, 1 are MALE; F, female, vrouw, femme, féminin, feminin, w,
2 are FEMALE (case ignored); anything else UNKNOWN.
Propose a change docs/concepts.md
Companies
company() finds a company by its identifier, search() by its name. Both ask the company
registers installed - omnistate/annuaire-entreprises for France - and answer Company models
(Concepts).
A company by its identifier
<?php
require __DIR__.'/vendor/autoload.php';
use Omnistate\AnnuaireEntreprises\AnnuaireEntreprises;
use Omnistate\Omnistate;
use Symfony\Component\HttpClient\HttpClient;
$omnistate = new Omnistate(companies: [new AnnuaireEntreprises(HttpClient::create())]);
$company = $omnistate->company('356 000 000'); // a SIREN; a SIRET or FR..356000000 work too
printf("%s (%s), %s, VAT %s\n", $company->name, $company->identifier, $company->isActive() ? 'active' : 'closed', $company->vatNumber);
printf(" legal form %s (%s), activity %s, section %s, category %s\n", $company->legalForm, $company->legalForm?->code, $company->activity, $company->activity?->section, $company->category);
printf(" headcount %s (code %s, %s), created on %s\n", $company->headcount, $company->headcount?->code, $company->headcount?->year, $company->createdOn?->format('Y-m-d'));
printf(" head office %s, %s\n", $company->headOffice?->identifier, $company->headOffice?->address);
printf(" %d establishments, %d open\n", $company->establishmentCount, $company->openEstablishmentCount);
foreach (array_slice($company->managers, 0, 3) as $manager) {
printf(" manager: %s%s\n", $manager->role, $manager->person ? ' (a person)' : ' (a company, '.$manager->identifier.')');
}
foreach (array_slice($company->finances, 0, 2) as $year) {
printf(" %d: revenue %s, net income %s %s\n", $year->year, $year->revenue ?? '-', $year->netIncome ?? '-', $year->currency);
}
echo ' labels: ', implode(', ', $company->labels) ?: 'none', "\n";
echo ' collective agreements: ', implode(', ', $company->collectiveAgreements) ?: 'none', "\n";
LA POSTE (356000000), active, VAT FR39356000000
legal form SA nationale à conseil d'administration (5510), activity 53.10Z, section H, category GE
headcount 10000+ (code 53, 2024), created on 1991-01-01
head office 35600000000048, DIRECTION GENERALE DE LA POSTE 9 RUE DU COLONEL PIERRE AVIA 75015 PARIS
13131 establishments, 8862 open
manager: Autre (a person)
manager: Autre (a person)
manager: Autre (a person)
2025: revenue 26700000000, net income 1474000000 EUR
labels: qualiopi, training_organization, finess, responsible_purchasing, alim_confiance, egapro
collective agreements: 5516, 9999
(run on 2026-10-25 in the harness, against the Recherche d'entreprises API)
The identifier is normalized before it is asked: spaces, dots and dashes go, letters are upper
case, and a French VAT number whose last nine digits are a valid SIREN is looked up by that SIREN
(FR39356000000 asks 356000000). The facade then asks the first company register whose
supports() takes it; omnistate/annuaire-entreprises takes a SIREN or a SIRET whose key is
right. Anything else is a NotSupportedException, before any request:
$omnistate->company('12345'); // NotSupportedException: No company registry installed knows "12345".
$omnistate->company('901821075'); // the same: a SIREN whose Luhn key is wrong
A company the register does not know is null.
By SIRET: an establishment among the establishments
A SIRET gives the company, with the establishment asked for among its establishments;
establishment() finds it again by its identifier, whether it is the head office or another:
$company = $omnistate->company('901 821 074 00019');
$establishment = $company->establishment('90182107400019');
$establishment->headOffice; // true
$establishment->address; // Address: street, postalCode, city, country, full, latitude, longitude
$establishment->status; // CompanyStatus::ACTIVE
Company::$establishments holds those the register's answer included - for the Recherche
d'entreprises API, the establishments that matched the question - without the head office, which
is headOffice. establishmentCount and openEstablishmentCount are the register's counts of
all of them.
La Poste's establishments are asked by their SIRET like any other: their numbers pass the Luhn
check or, La Poste's own rule, have digits summing to a multiple of 5 - its head office,
35600000000048 as the register writes it, passes Luhn.
By name
echo "\nSearch \"la poste\", 3 at most:\n";
foreach ($omnistate->search('la poste', 3) as $found) {
printf(" %s %-10s %-45s %s\n", $found->identifier, $found->name, $found->legalForm?->label ?? $found->legalForm, $found->headOffice?->address?->city);
}
Search "la poste", 3 at most:
356000000 LA POSTE SA nationale à conseil d'administration PARIS
883262073 LA POSTE Société civile immobilière COMMEQUIERS
928695022 LA POSTE Société civile immobilière SOURDEVAL
(the same run)
search() asks each company register in turn and answers the first non-empty list; with no
register installed, or none finding anything, it is []. The query is passed as the register
reads it: the Recherche d'entreprises API searches names, addresses and identifiers alike, at most
25 a page. A search answers the same Company models as a lookup, each with its head office and
the establishments that matched.
For a form that suggests companies as one types, the Symfony bundle has a field and its endpoint: Company search.
What a company holds
Everything the register gives is mapped, and raw keeps its whole answer:
| Property | From the Recherche d'entreprises API |
|---|---|
name, legalName, acronym |
nom_complet, nom_raison_sociale, sigle |
status |
CLOSED when etat_administratif is C |
legalForm |
INSEE's legal category, named from LegalForms::FORMS (45 common codes; another keeps its code without a name) |
activity |
the NAF rev. 2 code and its section |
category |
PME, ETI, GE |
headcount |
INSEE's range code, its bounds and year |
managers |
people (person, firstNames, birthYear, nationality) and companies (identifier: their SIREN) |
finances |
revenue and net income by year, newest first, in euros |
labels |
17 flags of the answer's complements (the list) |
collectiveAgreements |
the IDCC codes |
vatNumber |
derived from the SIREN (Siren::toVatNumber()), VIES not asked |
The register's own manual has the whole mapping and its limits: omnistate/annuaire-entreprises.
Errors
- The register did not answer - HTTP 429 (with
retryAfter), 5xx, 401/403, no answer, an answer that is not JSON:UnavailableException. That is not "no such company": ask again later. - An identifier no register takes:
NotSupportedException. - Unknown:
null; nothing found:[].
Propose a change docs/usage-companies.md
VAT numbers
vat() says whether an intra-EU VAT number exists and whose it is. Its shape is checked first,
without asking anyone; then the VAT register installed for its country is asked -
omnistate/vies, the European Commission's VIES, for the 27 member states and Northern Ireland.
<?php
require __DIR__.'/vendor/autoload.php';
use Omnistate\Exception\NotSupportedException;
use Omnistate\Exception\UnavailableException;
use Omnistate\Omnistate;
use Omnistate\Vies\Vies;
use Symfony\Component\HttpClient\HttpClient;
$omnistate = new Omnistate(vat: [new Vies(HttpClient::create())]);
foreach (['IE 6388047V', 'FR54901821074', 'gr 12345', 'NO123456789MVA', 'FR39356000000'] as $number) {
try {
$check = $omnistate->vat($number);
printf("%-16s %-9s source %-6s %s\n", $check->number, $check->valid ? 'valid' : 'not valid', $check->source, $check->name ?? '');
} catch (UnavailableException $e) {
printf("%-16s VIES did not answer: %s (retry after %s s)\n", $number, $e->getMessage(), $e->retryAfter ?? '-');
} catch (NotSupportedException $e) {
printf("%-16s %s\n", $number, $e->getMessage());
}
}
IE6388047V valid source vies GOOGLE IRELAND LIMITED
FR54901821074 not valid source format
GR12345 not valid source format
NO123456789MVA not valid source format
FR39356000000 valid source vies SA LA POSTE-DIRECTION GENERALE DE LA PO
(run on 2026-10-25 in the harness, against VIES)
The shape first
The facade normalizes the number with Identifier\VatNumber::normalize() - spaces, dots and
dashes go, upper case, GR becomes EL (VIES's prefix for Greece), and a SIREN or a SIRET
becomes its French number - and checks it with VatNumber::isWellFormed(): the member state's
shape after the prefix, and for France the key agreeing with the SIREN. A number that fails is
answered at once, with no request: a VatCheck whose valid is false and whose source is
format, its number the input without its spaces, upper case. That covers a key that does not
match (FR54901821074), a number too short (GR12345) and a country outside the EU
(NO123456789MVA, a Norwegian number).
A well-formed number goes to the first VAT register whose supports() takes its country. When
none does - no VAT register installed - it is a NotSupportedException (No VAT registry installed answers for DE.). With omnistate/vies installed, every well-formed number has a
register: the shapes and VIES's countries are the same list (VatNumber::FORMATS).
The consultation number
Given your own VAT number as the requester, VIES answers a consultation number - the proof that the check was made that day, to keep with a reverse-charge invoice:
$omnistate = new Omnistate(vat: [new Vies($http)], requester: 'FR53901821074'); // every check
$check = $omnistate->vat('IE6388047V'); // or vat($number, requester: '...') for one
$check->consultationNumber; // null when VIES gave none
$check->checkedAt; // VIES's date of the check
The requester is normalized like the number. A requester that has no member state's shape becomes
null and is not sent: the check is made, without a consultation number.
A check VIES refuses - INVALID_INPUT, INVALID_REQUESTER_INFO for a requester it does not
accept, VAT_BLOCKED - is an OmnistateException naming the code, never valid: false: asking
again gives the same answer, so fix the requester (or check without one).
Unavailable is not invalid
VIES only relays: each member state's service has its hours, and VIES limits concurrent calls.
omnistate/vies reads these answers as UnavailableException, never as a number that does not
exist: MS_UNAVAILABLE (retryAfter 600 s), TIMEOUT, SERVICE_UNAVAILABLE,
MS_MAX_CONCURRENT_REQ, GLOBAL_MAX_CONCURRENT_REQ and their _TIME variants, SERVER_BUSY,
IP_BLOCKED, any error code but the three refusals above and VIES's verdicts VALID and
INVALID, an HTTP 429 (its Retry-After, else 60 s), an answer that is not JSON, and no answer
at all.
try {
$check = $omnistate->vat($number);
} catch (UnavailableException $e) {
// keep the order pending, ask again in $e->retryAfter seconds (null: no estimate)
}
On 2026-10-25 France's service answered MS_MAX_CONCURRENT_REQ for FR53901821074 while
FR39356000000 was answered a few minutes later: a busy answer is passing.
In Symfony, #[VatNumber(checkExistence: true)] does the same check in a form, and lets the
value pass when VIES does not answer (Constraints).
What is kept
With a cache, a check is kept ttl seconds under the register's name, the normalized number and
the requester: the same number with another requester is asked again. A kept VatCheck carries
the checkedAt and consultationNumber of the first check - store the check itself when it is a
proof, not only the cache.
Propose a change docs/usage-vat.md
Domains and networks
domain() and network() ask the Internet registry - omnistate/iana's Rdap, which finds
through IANA's RDAP bootstrap the registry that answers for a domain's top-level domain, an IP
address's range or an AS number, then asks that registry's RDAP server (WHOIS's successor, in
JSON).
<?php
require __DIR__.'/vendor/autoload.php';
use Omnistate\Iana\Bootstrap;
use Omnistate\Iana\Rdap;
use Omnistate\Omnistate;
use Symfony\Component\HttpClient\HttpClient;
$http = HttpClient::create();
$omnistate = new Omnistate(internet: new Rdap($http, new Bootstrap($http)));
$domain = $omnistate->domain('Example.COM.');
printf("%s at %s\n registrar %s (IANA ID %s)\n registered %s, expires %s, changed %s\n statuses: %s\n name servers: %s, DNSSEC %s\n registrant: %s\n",
$domain->name, $domain->server, $domain->registrar, $domain->registrar?->ianaId,
$domain->registeredAt?->format('Y-m-d'), $domain->expiresAt?->format('Y-m-d'), $domain->changedAt?->format('Y-m-d'),
implode(', ', $domain->statuses), implode(', ', $domain->nameservers), $domain->dnssec ? 'yes' : 'no',
$domain->registrant ?? 'not published');
$network = $omnistate->network('193.0.6.139');
printf("\n193.0.6.139: %s %s, %s to %s, %s, %s\n abuse %s\n", $network->handle, $network->name, $network->startAddress, $network->endAddress, $network->type, $network->country, $network->abuse?->email);
$as = $omnistate->network('AS3333');
printf("\nAS3333: %s %s, %d to %d, at %s\n", $as->handle, $as->name, $as->startAutnum, $as->endAutnum, $as->server);
var_dump($omnistate->domain('not a domain'), $omnistate->domain('nobody-registered-this-0193.com'));
example.com at https://rdap.verisign.com/com/v1/
registrar RESERVED-Internet Assigned Numbers Authority (IANA ID 376)
registered 1995-08-14, expires 2027-08-13, changed 2026-08-14
statuses: client delete prohibited, client transfer prohibited, client update prohibited
name servers: elliott.ns.cloudflare.com, hera.ns.cloudflare.com, DNSSEC yes
registrant: not published
193.0.6.139: 193.0.0.0 - 193.0.7.255 RIPE-NCC, 193.0.0.0 to 193.0.7.255, ASSIGNED PA, NL
abuse abuse@ripe.net
AS3333: AS3333 RIPE-NCC-AS, 3333 to 3333, at https://rdap.db.ripe.net/
NULL
NULL
(run on 2026-10-25 in the harness: IANA's bootstrap files, then Verisign's and the RIPE NCC's RDAP servers)
A domain
The name is trimmed, lower-cased and loses its trailing dot (Example.COM. is example.com); with
ext-intl, an international name is asked in its ASCII form. Something that is not a domain
name is null without a request. The RDAP server is the one IANA's dns bootstrap lists for the
longest suffix of the name; a top-level domain with no RDAP server is null too, and so is a name
the registry does not know (HTTP 404).
What comes back (Domain): the registrar (its name, IANA ID,
page), the registration, expiration and last changed dates, the RDAP statuses, the name
servers, whether the delegation is DNSSEC-signed, and the registrant only when the registry still
publishes one: a contact with nothing but a handle (redacted, since the GDPR) is null.
An IP range, an autonomous system
network() takes an IPv4 or IPv6 address (the most specific range of IANA's ipv4 or ipv6
bootstrap names the regional registry), or an AS number with or without AS (AS3333, 3333;
the asn bootstrap). The answer is the range - its handle, name, type, country, first and last
address - or the autonomous system - its numbers -, its holder (registrant) and its abuse
contact, looked for among nested entities too.
The bootstrap files
Iana\Bootstrap reads IANA's four bootstrap files (https://data.iana.org/rdap/dns.json,
ipv4.json, ipv6.json, asn.json) the first time each is needed, and keeps them in memory for
the object's life. Given a cache, it keeps them there a day (86400 s, keys
omnistate.iana.bootstrap.<file>), as IANA asks clients not to fetch them on every lookup:
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
$cache = new FilesystemAdapter();
$rdap = new Rdap($http, new Bootstrap($http, $cache));
$omnistate = new Omnistate(internet: $rdap, cache: $cache); // the answers too
The Symfony bundle gives Bootstrap the same cache pool as the facade.
Errors
- IANA's bootstrap not answering, an HTTP error on it, an answer that is not JSON:
UnavailableException(retryAfterfrom a 429'sRetry-After, else 60 s). - An RDAP server answering HTTP 401, 403, 429 (
retryAfterfrom itsRetry-After, seconds or a date, else 60 s) or 5xx, or not answering:UnavailableException; its 404 or 410:null, not registered; its 400 or another 4xx: anOmnistateExceptionwith the server's reason. - No Internet registry installed:
NotSupportedException(No Internet registry installed: add omnistate/iana.).
Propose a change docs/usage-internet.md
Professionals and facilities
Omnistate\Registry\ProfessionalRegistryInterface is the one contract for the registers of
regulated professions: France's health professionals (omnistate/annuaire-sante, the RPPS),
later its lawyers (a future omnistate/cnb, not written). Whoever reads a professional need not
know which profession it is.
$omnistate->professional('10003461033'); // an RPPS number, or its IDNPS form 810003461033
$omnistate->professionals('Martin', postcode: '67'); // by name, practising in that postcode
$omnistate->facility('754567860'); // a FINESS number
An example, on recorded answers
omnistate/annuaire-sante needs a key from the ANS; without one at hand, this script runs the
real register class on the ANS's answers recorded in its Tests/Fixtures (fictional people of
the ANS's implementation guide), served by a MockHttpClient. With a key, replace the client by
HttpClient::create() and 'a-key' by the key.
<?php
require __DIR__.'/vendor/autoload.php';
use Omnistate\AnnuaireSante\AnnuaireSante;
use Omnistate\Omnistate;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
// The ANS's answers recorded in omnistate/annuaire-sante's Tests/Fixtures, in place of the API
$fixtures = __DIR__.'/vendor/omnistate/annuaire-sante/Tests/Fixtures/';
$http = new MockHttpClient(static fn (string $method, string $url) => new MockResponse((string) file_get_contents($fixtures.match (true) {
str_contains($url, '/Organization') => 'organization.json',
str_contains($url, 'identifier=') => 'practitioner-physician.json',
default => 'search.json',
})));
$omnistate = new Omnistate(professionals: [new AnnuaireSante($http, 'a-key')]);
$professional = $omnistate->professional('810003461033'); // the IDNPS form: the same RPPS
printf("%s %s (%s), %s [%s %s]\n", $professional->prefix, $professional->name(), $professional->identifier, $professional->profession, $professional->profession?->system, $professional->profession?->code);
foreach ($professional->specialties as $specialty) {
printf(" specialty %s (%s %s)\n", $specialty, $specialty->system, $specialty->code);
}
foreach ($professional->diplomas as $diploma) {
printf(" diploma %s (%s)\n", $diploma, $diploma->code);
}
foreach ($professional->workplaces as $workplace) {
printf(" works at %s, FINESS %s, %s, %s, %s\n", $workplace->name, $workplace->facility, $workplace->mode, $workplace->role, $workplace->active ? 'active' : 'ended');
}
echo ' secure e-mails: ', implode(', ', $professional->secureEmails) ?: 'none', "\n";
$facility = $omnistate->facility('754 567 860');
printf("\n%s (%s, %s), %s\n", $facility->name, $facility->identifier, $facility->kind, $facility->address);
foreach ($facility->types as $type) {
printf(" type %s (%s)\n", $type, $type->code);
}
echo "\nSearch \"a\":\n";
foreach ($omnistate->professionals('a', limit: 5) as $found) {
printf(" %s %-22s %s\n", $found->identifier, $found->name(), $found->profession);
}
M Arthur Saucier (10003461033), Médecin [TRE_G15-ProfessionSante 10]
diploma DE05 (DE05)
works at CABINET SAINT ANTOINE, FINESS 548812, liberal, FON-AU, active
secure e-mails: none
CH EURE-SEINE (754567860, site), 96 R DIDOT 75014 PARIS
type SA01 (SA01)
type 86.10Z (86.10Z)
Search "a":
10003461033 Arthur Saucier Médecin
10102727017 David CHATELIER Pharmacien
(run on 2026-10-25 in the harness, on the recorded answers - not against the ANS)
The workplace's facility is 548812 here: the recorded organization has no FINESS, and the
register's structure id stands in for it.
A professional
The identifier loses its spaces, dots and dashes, and an IDNPS (8 + RPPS, what Pro Santé Connect
and the Annuaire Santé call it) becomes its RPPS. The facade asks the first professional register
whose supports() takes it - omnistate/annuaire-sante takes eleven digits, without checking the
Luhn key (Rpps::isValid() does, and the #[Rpps] constraint). Anything else is a
NotSupportedException, before any request; a professional the register does not know is null.
By name
professionals($name, $postcode, $limit) asks each professional register in turn and answers the
first non-empty list. With a postcode, only those with a workplace whose postal code starts with
it are kept ('67' keeps the whole Bas-Rhin): omnistate/annuaire-sante then asks for 50
practitioners and filters them, so a common name in a small place may find none of those it
would have found among more. An empty name is [].
A facility
facility() normalizes a FINESS number (Finess::normalize(): nine characters, 2A / 2B for
Corsica) and asks the first professional register that implements FacilityRegistryInterface
too and whose supportsFacility() takes it.
The models
| Model | What it holds |
|---|---|
Professional |
identifier, familyName, givenName, prefix, profession and specialties and diplomas (Qualification: code, nomenclature, label), workplaces, phones, emails, secureEmails (MSSanté), languages, active, updatedAt, raw |
Workplace |
where and how one practises: name, address, facility (its FINESS), mode (liberal, salaried, volunteer), role, phones, secureEmails, active |
Facility |
identifier (FINESS), name, kind (legal entity or site), types, address, phones, emails, secureEmails, identifiers, active |
Every property, its type and default: Concepts. How the ANS's FHIR resources are read: omnistate/annuaire-sante.
A register that knows facilities too implements FacilityRegistryInterface (supportsFacility(),
facility()); Omnistate::facility() asks the professional registries that do.
Identifiers, checked without asking anyone
Rpps::isValid('10003461033'); // eleven digits, Luhn key
Rpps::normalize('810003461033'); // "10003461033": the IDNPS is "8" + RPPS
Rpps::toIdnps('10003461033'); // "810003461033", what Pro Santé Connect and the Annuaire Santé call it
Rpps::withKey('1000000001'); // "10000000017": a valid fictional number, for fixtures
Finess::isValid('580008803'); // nine characters, Luhn key; 2A / 2B for Corsica
The #[Rpps] and #[Finess] constraints check the same (Constraints).
Symfony
omnistate:
annuaire_sante:
api_key: '%env(ESANTE_API_KEY)%' # empty: every call throws UnavailableException
Registries of your own implementing ProfessionalRegistryInterface are tagged
omnistate.professional_registry and asked in turn.
A registry that does not answer throws UnavailableException: that is not "no such professional".
Without a key, omnistate/annuaire-sante throws it on every call, before any request.
Propose a change docs/professionals.md
Civil registers
What public registers index about a person - births, baptisms, marriages, deaths, burials, and
the other documents an archive holds - through one contract, CivilRegistryInterface.
use Omnistate\Model\{CivilQuery, CivilRecordKind, Period};
$records = $omnistate->civilRecords(new CivilQuery(
familyName: 'Chirac', givenName: 'Jacques',
born: Period::year(1932), // or Period::around(1932), Period::years(1930, 1935)
country: 'FR', // ISO 3166-1 alpha-2; null: every register
kinds: [CivilRecordKind::DEATH], // empty: any kind
));
$record = $records[0];
$record->kind; // CivilRecordKind::DEATH
$record->date; // PartialDate "2019-09-26"
$record->place; // Place: name, code (INSEE), postcode, region, country, coordinates
$record->principal()->name(); // "Jacques Rene Chirac" - $record->persons names everyone in the act
$record->archive; // who keeps the original, its reference
$record->images; // scans of the act, when the register publishes them
$record->url; // its page at the register
$omnistate->civilRecord($record->source, $record->identifier); // the whole record again: every person, the scans
The registers
| Package | Countries | Kinds of records | Picture of the act | Access |
|---|---|---|---|---|
omnistate/matchid |
FR | deaths, since 1970 (INSEE's file) | no | free, no key (a token raises the quota) |
omnistate/openarchieven |
NL, BE, FR, SR | births, baptisms, marriages, deaths, burials, others (population registers, notarial deeds...) | yes, when the archive scanned it | free, no key, 4 calls a second |
omnistate/national-archives-uk |
GB | others only: a catalogue of archives (wills, service records, parish papers), not civil registration | no | free, no key |
omnistate/nara |
US | others only: the National Archives Catalog (pension files, military service records, draft cards, naturalization indexes), not civil registration | yes, links to the scans at NARA | free, API key, 10,000 calls a month |
Each register says what it covers, so a search screen can tell before asking:
foreach ($omnistate->civilRegistries() as $registry) { // or civilRegistries('NL', CivilRecordKind::BIRTH)
$registry->name(); // "openarchieven"
$registry->countries(); // ['NL', 'BE', 'FR', 'SR']
$registry->kinds(); // [CivilRecordKind::BIRTH, ...]
$registry->period(); // Period "1970/…" or null
$registry->hasImages(); // true
$registry->supports($query);
}
$omnistate->civilCountries(); // ['BE', 'FR', 'GB', 'NL', 'SR']
$omnistate->civilRegistry('matchid'); // one register by its name, null when not installed
civilRegistries($country, $kind) keeps the registers that declare that country and that kind;
it asks no one.
What each register searches on
A register uses the fields of the CivilQuery it can search on and ignores the rest; its
supports() says beforehand whether the question is one for it.
| Field | matchid | openarchieven | national-archives-uk | nara |
|---|---|---|---|---|
familyName, givenName |
yes (one of them needed) | yes, in one name |
yes, full text | yes, full text |
born |
yes | the year, with births and baptisms asked | - | - |
died |
yes | the year, with deaths and burials asked | - | - |
dated |
as the death date when died is not set |
the year of the event | the dates the document covers | the dates of the file's series |
place |
the place of death | the place of the event | added to the full text | added to the full text |
country |
FR only | NL, BE, FR, SR | GB only | US only |
kinds |
must want DEATH | one kind sent, several sorted out after | must want OTHER | must want OTHER |
sex |
yes | - | - | - |
limit, page |
yes | yes (100 a page at most) | yes | yes |
supports() is false without a name for all four; matchID refuses a death looked for before 1970.
Each register's manual has the details.
An example, live
<?php
require __DIR__.'/vendor/autoload.php';
use Omnistate\Exception\NotSupportedException;
use Omnistate\MatchId\MatchId;
use Omnistate\Model\CivilQuery;
use Omnistate\Model\CivilRecordKind;
use Omnistate\Model\Period;
use Omnistate\NationalArchivesUk\NationalArchivesUk;
use Omnistate\Omnistate;
use Omnistate\OpenArchieven\OpenArchieven;
use Symfony\Component\HttpClient\HttpClient;
$http = HttpClient::create();
$omnistate = new Omnistate(civil: [new MatchId($http), new OpenArchieven($http), new NationalArchivesUk($http)]);
echo 'countries: ', implode(' ', $omnistate->civilCountries()), "\n";
foreach ($omnistate->civilRegistries() as $registry) {
printf(" %-21s %-12s %-50s since %s, images: %s\n", $registry->name(), implode(',', $registry->countries()),
implode(',', array_map(static fn (CivilRecordKind $kind) => $kind->value, $registry->kinds())),
$registry->period() ?? '-', $registry->hasImages() ? 'yes' : 'no');
}
// France's deaths
$query = new CivilQuery(familyName: 'Chirac', givenName: 'Jacques', born: Period::year(1932), country: 'FR', kinds: [CivilRecordKind::DEATH], limit: 3);
foreach ($omnistate->civilRecords($query) as $record) {
$person = $record->principal();
printf("\n%s %s: %s, died %s in %s (%s), born %s in %s, act %s\n %s\n archive: %s\n",
$record->source, $record->identifier, $person->name(), $record->date, $record->place, $record->place?->code,
$person->birthDate, $person->birthPlace, $record->number, $record->url, $record->archive);
}
// The Netherlands: a line per person, then the whole act
$lines = $omnistate->civilRecords(new CivilQuery(familyName: 'van Gogh', givenName: 'Vincent Willem', dated: Period::year(1853), country: 'NL', kinds: [CivilRecordKind::BIRTH], limit: 2));
foreach ($lines as $line) {
printf("\n%s %s: %s %s, %s, %s (%s)\n", $line->source, $line->identifier, $line->kind->value, $line->date, $line->place, $line->principal()?->fullName, $line->principal()?->role);
}
if ($lines) {
$act = $omnistate->civilRecord($lines[0]->source, $lines[0]->identifier);
foreach ($act->persons as $person) {
printf(" %-10s %s\n", $person->role, $person->name());
}
printf(" archive: %s\n %d image(s)\n", $act->archive, count($act->images));
}
// A question no register installed takes
try {
$omnistate->civilRecords(new CivilQuery(familyName: 'Tanaka', country: 'JP'));
} catch (NotSupportedException $e) {
echo "\n", $e->getMessage(), "\n";
}
// The UK: documents that name someone, kind OTHER
foreach ($omnistate->civilRecords(new CivilQuery(familyName: 'Smith', givenName: 'John', dated: Period::years(1770, 1780), country: 'GB', limit: 2)) as $paper) {
printf("\n%s %s: %s\n %s, period %s\n", $paper->source, $paper->identifier, $paper->title, $paper->archive, $paper->period);
}
countries: BE FR GB NL SR
matchid FR death since 1970/…, images: no
openarchieven NL,BE,FR,SR birth,baptism,marriage,death,burial,other since -, images: yes
national-archives-uk GB other since -, images: no
matchid fohpDS17K0Ez: Jacques Rene Chirac, died 2019-09-26 in Paris 6e Arrondissement, France (75106), born 1932-11-29 in Paris 5e Arrondissement, France, act 129
https://deces.matchid.io/id/fohpDS17K0Ez
archive: INSEE, fichier des personnes décédées, fichier 2019, ligne 505032
openarchieven ins:a28e3333-5b33-04af-7b55-c671109714f4: Jacques Rene Chirac, died 2019-09-26 in Paris (), born in , act
https://www.openarchieven.nl/ins:a28e3333-5b33-04af-7b55-c671109714f4/en
archive: L'Institut national de la statistique et des études économiques (INSEE)
openarchieven ins:154d0675-7401-d7eb-325f-746a8e958df7: Jean Jacques Marie Chirac, died 2000-08-08 in Clermont-Ferrand (), born in , act
https://www.openarchieven.nl/ins:154d0675-7401-d7eb-325f-746a8e958df7/en
archive: L'Institut national de la statistique et des études économiques (INSEE)
openarchieven bhi:78a37833-d731-ffe5-184c-f9f424826871: birth 1853-03-30, Zundert, Vincent Willem van Gogh (Child)
openarchieven wba:8759da72-f8d9-11df-a690-cd95c1e286e2: birth 1853-03-30, Zundert, Vincent Willem van Gogh (Child)
Kind Vincent Willem van Gogh
Moeder Anna Cornelia Carbentus
Vader Theodorus van Gogh
archive: Brabants Historisch Informatie Centrum, Bron: boek, Deel: 9509, Periode: 1853, Geboorteregister Zundert 1853, n° 29
1 image(s)
No civil registry installed takes that question.
national-archives-uk 2bf7ed32-739e-4cc8-b63d-2702b680aa96: John Smith
Devon Archives and Local Studies Service (South West Heritage Trust), 1255 A/PO 31, period 1770
national-archives-uk C7378568: Smith, John
The National Archives, Kew, ASSI 45/30/1/108-113, period 1771
(run on 2026-10-25 in the harness, against matchID, Open Archives and Discovery)
What the run shows:
- A question for France's deaths is one for two registers: matchID (INSEE's file) and Open
Archives (which indexes the same INSEE deaths under the archive code
ins).civilRecords()asks both and answers their records one after the other, each up to the query'slimit- the same death can come twice, once from each. - A search line of Open Archives is one person of one act: the whole name in
fullName, the role as the archive writes it;civilRecord()gives the whole act, everyone named with their role in Dutch (Kind,Moeder,Vader), the archive's reference and the scans. - A country no register holds (
JP) is aNotSupportedException, before any request. - Discovery's records are documents (kind
other), dated by the years they cover.
A register that is down
UnavailableException, never an empty list: "no answer" is not "no record". civilRecords($query)
asks every register the question is one for; one that is down throws, and takes the others'
answers with it. A screen that must keep what the others found asks them one by one:
foreach ($omnistate->civilRegistries() as $registry) {
if (!$registry->supports($query)) { continue; }
try {
$found[$registry->name()] = $omnistate->civilRecords($query, $registry->name());
} catch (UnavailableException $e) {
$down[$registry->name()] = $e->retryAfter;
}
}
NotSupportedException: no register installed takes the question (its country, its kinds, no
name). OmnistateException: a register refused the question itself (matchID on a date it cannot
read, Open Archives on an error it answers in the body).
With a cache, each register's answer to a query is kept under the query's key(), a record
found again under its register and identifier; an exception is never kept.
The models
CivilQuery:familyName,givenName,born,died,dated(the event's date, whatever it is),place,country,kinds,sex,limit(1 to 100),page. A register uses what it can search on.CivilRecord:identifier,kind,source,date,place,persons,archive,url,images,title,number(the act's number),description,period(a document dated by a span),raw.CivilPerson:familyName,givenNames,sex,role(as the register writes it),birthDate,birthPlace,deathDate,deathPlace,age,profession,fullName.PartialDate: a year, maybe a month, maybe a day - what is not known is null, never a made-up 1st of January.PartialDate::parse()reads19321129,1932-11,29/11/1932,19320000.Period:year(),years(),since(),around(),day(),contains().Place,Archive, the enumsCivilRecordKind(withgedcom(): BIRT, CHR, MARR, DEAT, BURI) andSex.Identifier\CountryCode:alpha2('FRA')isFR. No network.
Every property, its type and default: Concepts.
Symfony
OmnistateBundle registers the civil register packages installed and tags them
omnistate.civil_registry; a class of yours implementing CivilRegistryInterface is tagged by
autoconfiguration and asked too.
omnistate:
matchid:
token: '%env(MATCHID_TOKEN)%' # optional
nara:
api_key: '%env(NARA_API_KEY)%'
What is not there
- England and Wales' births, marriages and deaths: the General Register Office has no public API.
- The United States' births, marriages and deaths: kept by the states and counties, not by NARA;
omnistate/narais a catalogue of federal files, not an index of acts. - Japan: the koseki are not public.
- Collaborative trees (FamilySearch, Geneanet) are not registers.
Propose a change docs/civil.md
Identifiers
Omnistate\Identifier\ checks identifiers without asking anyone: their shape and their check
digit. A well-written identifier can still belong to no one - only a register says that. These
classes need omnisense/omnistate alone (no register, no HTTP client); the facade and the
registers use them before any request, and the Symfony constraints are built on them.
<?php
require __DIR__.'/vendor/autoload.php';
use Omnistate\Identifier\CountryCode;
use Omnistate\Identifier\Finess;
use Omnistate\Identifier\Luhn;
use Omnistate\Identifier\Rpps;
use Omnistate\Identifier\Siren;
use Omnistate\Identifier\Siret;
use Omnistate\Identifier\VatNumber;
$show = static fn (string $call, mixed $value) => printf("%-40s %s\n", $call, var_export($value, true));
$show("Siren::normalize('901 821 074')", Siren::normalize('901 821 074'));
$show("Siren::isValid('901821075')", Siren::isValid('901821075'));
$show("Siren::toVatNumber('356000000')", Siren::toVatNumber('356000000'));
$show("Siret::isValid('35600000000048')", Siret::isValid('35600000000048'));
$show("Siret::siren('901 821 074 00019')", Siret::siren('901 821 074 00019'));
$show("VatNumber::normalize('gr 123456789')", VatNumber::normalize('gr 123456789'));
$show("VatNumber::normalize('90182107400019')", VatNumber::normalize('90182107400019'));
$show("VatNumber::normalize('DE12345')", VatNumber::normalize('DE12345'));
$show("VatNumber::isWellFormed('FR54901821074')", VatNumber::isWellFormed('FR54901821074'));
$show("VatNumber::country('EL123456789')", VatNumber::country('EL123456789'));
$show("Rpps::normalize('810003461033')", Rpps::normalize('810003461033'));
$show("Rpps::toIdnps('10003461033')", Rpps::toIdnps('10003461033'));
$show("Rpps::withKey('1000000001')", Rpps::withKey('1000000001'));
$show("Finess::isValid('2A0000010')", Finess::isValid('2A0000010'));
$show("Finess::department('67 000 123 4')", Finess::department('67 000 123 4'));
$show("Luhn::isValid('79927398713')", Luhn::isValid('79927398713'));
$show("CountryCode::alpha2('FRA')", CountryCode::alpha2('FRA'));
$show("CountryCode::alpha3('nl')", CountryCode::alpha3('nl'));
$show("CountryCode::isValid('UK')", CountryCode::isValid('UK'));
Siren::normalize('901 821 074') '901821074'
Siren::isValid('901821075') false
Siren::toVatNumber('356000000') 'FR39356000000'
Siret::isValid('35600000000048') true
Siret::siren('901 821 074 00019') '901821074'
VatNumber::normalize('gr 123456789') 'EL123456789'
VatNumber::normalize('90182107400019') 'FR53901821074'
VatNumber::normalize('DE12345') NULL
VatNumber::isWellFormed('FR54901821074') false
VatNumber::country('EL123456789') 'EL'
Rpps::normalize('810003461033') '10003461033'
Rpps::toIdnps('10003461033') '810003461033'
Rpps::withKey('1000000001') '10000000017'
Finess::isValid('2A0000010') false
Finess::department('67 000 123 4') '67'
Luhn::isValid('79927398713') true
CountryCode::alpha2('FRA') 'FR'
CountryCode::alpha3('nl') 'NLD'
CountryCode::isValid('UK') false
(run on 2026-10-29 in the harness; no network)
Every normalize() removes spaces, dots and dashes first, and answers null when what is left
does not have the identifier's shape.
Siren
A French company's SIREN: nine digits, the last a Luhn check digit.
| Method | |
|---|---|
normalize(?string): ?string |
nine digits, or null |
isValid(?string): bool |
normalized, and the Luhn key right |
toVatNumber(string): string |
the French intra-EU VAT number: FR + (12 + 3 × (SIREN mod 97)) mod 97 on two digits + the SIREN; \InvalidArgumentException when it is not nine digits |
Siret
A French establishment's SIRET: the company's SIREN and a five-digit NIC.
| Method | |
|---|---|
normalize(?string): ?string |
fourteen digits, or null |
isValid(?string): bool |
its SIREN valid, and the fourteen digits pass the Luhn check - La Poste's (SIREN 356000000) also when the sum of the digits is a multiple of 5, the rule its establishments follow (its head office 35600000000048 passes Luhn) |
siren(string): string |
its first nine digits; \InvalidArgumentException when it is not fourteen digits |
VatNumber
Intra-EU VAT numbers as VIES writes them: a country prefix and the number in each country's shape
(VatNumber::FORMATS, a regular expression per prefix).
| Prefix | Shape after it | Prefix | Shape after it |
|---|---|---|---|
AT |
U + 8 digits |
IT |
11 digits |
BE |
0 or 1 + 9 digits | LT |
9 or 12 digits |
BG |
9 or 10 digits | LU |
8 digits |
CY |
8 digits + a letter | LV |
11 digits |
CZ |
8 to 10 digits | MT |
8 digits |
DE |
9 digits | NL |
9 digits + B + 2 digits |
DK |
8 digits | PL |
10 digits |
EE |
9 digits | PT |
9 digits |
EL (Greece) |
9 digits | RO |
2 to 10 digits |
ES |
a letter or digit + 7 digits + a letter or digit | SE |
12 digits |
FI |
8 digits | SI |
8 digits |
FR |
2 characters (digits or letters but I and O) + 9 digits | SK |
10 digits |
HR |
11 digits | XI (Northern Ireland) |
9 or 12 digits, GD + 3 digits, HA + 3 digits |
HU |
8 digits | ||
IE |
7 digits + a letter A-W + an optional letter A-I, or a digit + a letter, + or * + 5 digits + a letter A-W |
| Method | |
|---|---|
normalize(?string): ?string |
upper case; a SIREN or a SIRET becomes its French number; GR becomes EL; null when the prefix is not one of the table's or the rest does not have its shape |
isWellFormed(?string): bool |
normalized, and for a French number with a numeric key, the key agreeing with the SIREN (Siren::toVatNumber()) |
country(string): string |
the first two letters |
Rpps
A French health professional's RPPS number: eleven digits, the last a Luhn check digit. Its
national form, the IDNPS, is 8 followed by the RPPS.
| Method | |
|---|---|
normalize(?string): ?string |
eleven digits; a twelve-digit IDNPS gives its RPPS; else null |
isValid(?string): bool |
normalized, and the Luhn key right |
toIdnps(string): string |
8 + the RPPS; \InvalidArgumentException when it is not one |
withKey(string $tenDigits): string |
ten digits with their Luhn key appended - a valid fictional number for fixtures; \InvalidArgumentException without ten digits |
Finess
A French health or social facility's FINESS number: nine characters, the département first (two
digits, or 2A / 2B for Corsica), the last a Luhn check digit over the eight before it. A legal
entity (FINESS juridique) and each of its sites (FINESS géographique) have one each.
| Method | |
|---|---|
normalize(?string): ?string |
upper case, nine characters of that shape, or null |
isValid(?string): bool |
normalized, and the Luhn check right, A counting as 0 and B as 1 |
department(string): ?string |
the first two characters: 67, 2A |
Luhn and CountryCode
Luhn::isValid(string $digits): bool is the Luhn check (ISO/IEC 7812): every second digit from
the right doubled; false for anything but digits.
CountryCode keeps ISO 3166-1 codes the way Omnistate does, with two letters: ALPHA3 maps the
249 alpha-3 codes officially assigned to their alpha-2 codes. alpha2(?string) takes either form
(FRA, fr) and answers null for anything else (UK is not an ISO code: Great Britain is
GB); alpha3(?string) the other way; isValid(?string).
Propose a change docs/usage-identifiers.md
Configuration reference
Omnistate has no configuration file of its own: the facade and each register take their settings as constructor arguments. In Symfony, the bundle's tree sets them (Symfony).
The facade
new Omnistate\Omnistate(...), every argument optional and best given by name:
| Argument | Type | Default | |
|---|---|---|---|
companies |
iterable<CompanyRegistryInterface> |
[] |
the company registers, asked in this order |
vat |
iterable<VatRegistryInterface> |
[] |
the VAT registers |
internet |
?InternetRegistryInterface |
null |
the one Internet registry (Iana\Rdap); null: domain() and network() throw NotSupportedException |
cache |
?Symfony\Contracts\Cache\CacheInterface |
null |
where answers are kept; null: every call asks the register |
ttl |
int |
86400 |
seconds an answer is kept |
requester |
?string |
null |
your own VAT number, sent with every check that names no other: VIES then answers a consultation number |
professionals |
iterable<ProfessionalRegistryInterface> |
[] |
the professional registers; those implementing FacilityRegistryInterface answer facility() too |
civil |
iterable<CivilRegistryInterface> |
[] |
the civil registers, asked in this order |
The iterables are read once, when the facade is built (a generator is consumed then).
The cache
With a cache, every question that reaches a register is kept ttl seconds under
omnistate.<xxh128 hash> of a key made of what was asked:
| Method | Key, before hashing |
|---|---|
company() |
company.<register>.<normalized identifier> |
search() |
search.<register>.<limit>.<query, trimmed, lower case> |
vat() |
vat.<register>.<normalized number>.<normalized requester> |
professional() |
professional.<register>.<normalized identifier> |
professionals() |
professionals.<register>.<limit>.<postcode>.<name, trimmed, lower case> |
facility() |
facility.<register>.<normalized identifier> |
civilRecords() |
civil.search.<register>.<CivilQuery::key()> |
civilRecord() |
civil.record.<register>.<identifier> |
domain() |
domain.<normalized name> |
network() |
network.<normalized address> |
What is kept is the register's answer, whatever it is: a null (not known) and an empty list are
kept too - a company created this morning stays unknown until the entry expires. An exception is
never kept: a register that did not answer is asked again next time. search() and
professionals() keep each register's answer separately, the empty ones included, while they
look for the first that finds something.
Iana\Bootstrap takes a cache of its own (often the same pool) for IANA's bootstrap files, kept
86400 seconds under omnistate.iana.bootstrap.<dns|ipv4|ipv6|asn>, whatever the facade's ttl.
The registers
Every register takes the HTTP client to call with first - any
Symfony\Contracts\HttpClient\HttpClientInterface: HttpClient::create(), the application's, a
MockHttpClient in a test - and a timeout in seconds, applied to each request.
| Class | Constructor | Defaults |
|---|---|---|
AnnuaireEntreprises\AnnuaireEntreprises |
(HttpClientInterface $http, float $timeout = 10) |
URL = https://recherche-entreprises.api.gouv.fr/search (a constant, not an argument) |
Vies\Vies |
(HttpClientInterface $http, float $timeout = 10) |
URL = https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number (a constant) |
Iana\Bootstrap |
(HttpClientInterface $http, ?CacheInterface $cache = null, float $timeout = 10) |
URL = https://data.iana.org/rdap/%s.json (a constant) |
Iana\Rdap |
(HttpClientInterface $http, Bootstrap $bootstrap, float $timeout = 10) |
the server IANA's bootstrap names |
AnnuaireSante\AnnuaireSante |
(HttpClientInterface $http, ?string $apiKey = null, float $timeout = 10, string $url = self::URL) |
URL = https://gateway.api.esante.gouv.fr/fhir/v2; no key: every call is an UnavailableException |
MatchId\MatchId |
(HttpClientInterface $http, ?string $token = null, float $timeout = 10, string $url = self::URL) |
URL = https://deces.matchid.io/deces/api/v1; the token optional |
OpenArchieven\OpenArchieven |
(HttpClientInterface $http, float $timeout = 10, string $lang = 'en', string $url = self::URL) |
URL = https://api.openarchieven.nl/1.1; lang: en, nl, de or fr, the language of the labels |
NationalArchivesUk\NationalArchivesUk |
(HttpClientInterface $http, float $timeout = 10, string $url = self::URL) |
URL = https://discovery.nationalarchives.gov.uk/API |
Nara\Nara |
(HttpClientInterface $http, ?string $apiKey = null, float $timeout = 10, string $url = self::URL) |
URL = https://catalog.archives.gov/api/v2; no key: the Catalog answers its web page, an UnavailableException |
The url arguments exist for a proxy or a test server; none of these services has a sandbox
address. What each register does with its settings: its own manual
(Registers).
The Symfony bundle
| Key | Sets |
|---|---|
omnistate.cache |
the facade's cache and Bootstrap's cache (a pool's service id, cache.app by default) |
omnistate.ttl |
the facade's ttl |
omnistate.timeout |
every register's timeout |
omnistate.requester |
the facade's requester |
omnistate.annuaire_sante.api_key, .url |
AnnuaireSante's apiKey and url |
omnistate.matchid.token |
MatchId's token |
omnistate.nara.api_key |
Nara's apiKey |
Types and defaults: Symfony.
Propose a change docs/configuration.md
Errors
Three exceptions, in Omnistate\Exception:
| Exception | Parent | Means |
|---|---|---|
OmnistateException |
\RuntimeException |
the family's base class; thrown as such when a register refused the question itself |
NotSupportedException |
OmnistateException |
no register installed takes that question: add its package |
UnavailableException |
OmnistateException |
the register did not answer; public readonly ?int $retryAfter, in seconds, when there is an estimate |
UnavailableException::__construct(string $message, ?int $retryAfter = null, ?\Throwable $previous = null): the HTTP client's exception, when there was one, is the previous exception.
A register that calls with a key or a token (NARA's x-api-key, matchID's bearer, Annuaire Santé's
ESANTE-API-KEY) keeps it out of what it throws: the HTTP client's message with it masked
(Http\Redaction::text()), the previous exception a copy of the client's (Redaction::copy(): the
same class and code), and no trace of the chain keeping its arguments, where the request's headers
stood (zend.exception_ignore_args off).
The family's rule
Not knowing is an answer; not answering is not. No register reads one as the other:
| What happens | What the application gets |
|---|---|
| the register does not know the company, the professional, the record, the domain | null from a lookup, [] from a search |
| a VAT number that does not exist | a VatCheck with valid false |
| the identifier or the question is not one for any register installed | NotSupportedException, from the facade, before any request |
| HTTP 401 or 403 (a key missing or refused) | UnavailableException |
| HTTP 429 | UnavailableException with retryAfter |
| HTTP 5xx, no answer (DNS, connection, timeout), an answer that cannot be read (an HTML page in place of JSON) | UnavailableException |
| a question the service refuses as malformed | as each register documents: [], null, or an OmnistateException with the service's reason |
So a screen can say "unknown" only on null, [] or valid: false, and "the register did not
answer, try again" on UnavailableException. A VAT check that cannot be made is never a
VatCheck saying "not valid". The conformance suite checks the rule in every register
(Testing).
When each is thrown
NotSupportedException, by the facade only:
| Message | Thrown by |
|---|---|
No company registry installed knows "<identifier>". |
company() |
No VAT registry installed answers for <country>. |
vat(), for a well-formed number |
No professional registry installed knows "<identifier>". |
professional() |
No facility registry installed knows "<identifier>". |
facility() |
No civil registry installed takes that question. |
civilRecords() without a register named |
The civil registry "<name>" is not installed, or does not take that question. |
civilRecords() with a register named |
The civil registry "<name>" is not installed. |
civilRecord() |
No Internet registry installed: add omnistate/iana. |
domain(), network() |
UnavailableException, by the registers, and its retryAfter:
| Register | HTTP 429 | Other cases |
|---|---|---|
annuaire-entreprises |
Retry-After, else 1 s |
5xx; 401, 403 and every 4xx but 400 and 404; no answer; not JSON |
vies |
Retry-After (at least 1), else 60 s |
VIES's MS_UNAVAILABLE (600 s), TIMEOUT, SERVICE_UNAVAILABLE, MS_MAX_CONCURRENT_REQ, GLOBAL_MAX_CONCURRENT_REQ (and their _TIME variants), SERVER_BUSY, IP_BLOCKED, any other error code but VALID, INVALID and the three refusals below; an answer without valid; no answer; not JSON |
iana (Bootstrap) |
Retry-After (at least 1), else 60 s |
any HTTP error on a bootstrap file, no answer, not JSON |
iana (Rdap) |
Retry-After (seconds or a date, at least 1), else 60 s |
401, 403, 5xx, no answer, not JSON |
annuaire-sante |
Retry-After (seconds or a date, at least 1), else 1 s |
no key (before any request), 401, 403, 5xx, another 4xx but 404, no answer, not JSON |
matchid |
Retry-After, else 60 s |
401, 403, 5xx, no answer, not JSON |
openarchieven |
Retry-After (at least 1), else 1 s |
401, 403, 5xx, no answer, not JSON |
national-archives-uk |
Retry-After (at least 1), else 60 s |
401, 403, 5xx, no answer, not JSON |
nara |
the seconds until the first of the next month (UTC), at least 60 | 401, 403, 5xx, the Catalog's web page in place of JSON (no key or a wrong one), no answer |
OmnistateException itself, a refusal of the question:
| Register | When |
|---|---|
vies |
VIES's INVALID_INPUT, INVALID_REQUESTER_INFO, VAT_BLOCKED, naming the code |
iana (Rdap) |
an RDAP server's 400 or another 4xx but 404 and 410, with its reason (the error's title and description) |
matchid |
an HTTP status of 400 or more not listed above (a date it cannot read: 400), with matchID's message |
openarchieven |
an error_code in the body (HTTP 200) on a search; on find() it is null |
national-archives-uk |
a 4xx other than 401, 403, 429, and 204/404 on find() |
nara |
a 4xx other than 401, 403, 429, and 404 on find() |
Other exceptions, from code an application calls directly:
\InvalidArgumentException:Siren::toVatNumber(),Siret::siren(),Rpps::toIdnps()andRpps::withKey()given something that is not one;AnnuaireSante::professional()and::facility()called directly with something that is not an RPPS or a FINESS number (the facade checkssupports()first and throwsNotSupportedExceptioninstead).Symfony\Component\Validator\Exception\UnexpectedTypeException: a constraint validator given another constraint (a wiring error).
What to catch
use Omnistate\Exception\NotSupportedException;
use Omnistate\Exception\UnavailableException;
try {
$company = $omnistate->company($typed);
} catch (NotSupportedException) {
// not an identifier any register installed takes: say so in the form
} catch (UnavailableException $e) {
// the register did not answer: keep what was typed, ask again in $e->retryAfter seconds
}
if (null === $company) {
// no such company
}
Catching OmnistateException catches all three. A cache never keeps an exception: the next call
asks again.
Every register follows the rule, the cases the conformance suite does not reach included (Verified, and not).
Propose a change docs/errors.md
Testing
Omnistate ships no fake register: the contracts are small enough for a test to implement one, and
every register takes its HTTP client, so a MockHttpClient (from symfony/http-client, which
every register already requires) puts recorded answers in place of the service. Neither way
reaches the network.
An application's code: a register of its own
The facade takes any implementation of the contracts. An anonymous class answers what the test needs:
use Omnistate\Exception\UnavailableException;
use Omnistate\Model\Company;
use Omnistate\Model\CompanyStatus;
use Omnistate\Omnistate;
use Omnistate\Registry\CompanyRegistryInterface;
use Omnistate\Vies\Vies;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class SupplierCheckTest extends TestCase
{
/** An in-memory register: the application's code is tested without HTTP at all. */
public function testAClosedCompanyIsRefused(): void
{
$registry = new class implements CompanyRegistryInterface {
public function name(): string { return 'fake'; }
public function supports(string $identifier): bool { return true; }
public function company(string $identifier): ?Company
{
return new Company($identifier, 'ACME', CompanyStatus::CLOSED, 'FR', 'fake');
}
public function search(string $query, int $limit = 10): array { return []; }
};
$omnistate = new Omnistate(companies: [$registry]);
self::assertFalse($omnistate->company('901821074')->isActive());
}
/** A real register on a MockHttpClient: its mapping and its errors, offline. */
public function testVatUnavailableIsNotInvalid(): void
{
$omnistate = new Omnistate(vat: [new Vies(new MockHttpClient(
new MockResponse('{"actionSucceed":false,"errorWrappers":[{"error":"MS_UNAVAILABLE"}]}'),
))]);
$this->expectException(UnavailableException::class);
$omnistate->vat('DE123456789');
}
public function testAValidNumber(): void
{
$omnistate = new Omnistate(vat: [new Vies(new MockHttpClient(
new MockResponse('{"valid":true,"requestDate":"2026-10-25T10:00:00Z","name":"ACME GMBH","address":"---","requestIdentifier":""}'),
))]);
$check = $omnistate->vat('DE123456789');
self::assertTrue($check->valid);
self::assertSame('ACME GMBH', $check->name);
self::assertNull($check->address, '"---" is nothing');
}
}
$ vendor/bin/phpunit SupplierCheckTest.php
... 3 / 3 (100%)
Time: 00:00.026, Memory: 8.00 MB
OK (3 tests, 5 assertions)
(run on 2026-10-25 in the harness, PHPUnit 11.5, PHP 8.4)
Things worth testing in an application, because the facade does them for it:
UnavailableExceptionis not "not found": the screen that shows a company must not say "unknown" when the register did not answer.- A VAT number with no member state's shape is a
VatCheckwithvalidfalse andsourceformat, without any register asked: a test needs no mock for it. - With a cache, a second question is not asked again: give the facade a
Symfony\Component\Cache\Adapter\ArrayAdapterand count the requests the mock receives.
Each register's own tests show its recorded answers: Tests/Fixtures/*.json in every
omnistate/* package, which ship with the package and can be served by a MockHttpClient
(an example).
In Symfony
In a functional test, replace the http_client service the registers are built on by a
MockHttpClient (framework.http_client.mock_response_factory in the test environment), or
replace Omnistate\Omnistate itself (static::getContainer()->set(Omnistate::class, $fake), the
service being public). The bundle's own tests build a container by hand with a synthetic
http_client (Tests/Bridge/OmnistateBundleTest.php).
The conformance suite
Omnistate\Testing\RegistryContractTests is the family's contract as a PHPUnit trait, offline.
Every register's Tests/<Service>ContractTest.php uses it, and a register an application writes
can too. It needs phpunit/phpunit and symfony/http-client (in the register's require-dev).
use Omnistate\MatchId\MatchId;
use Omnistate\Model\CivilQuery;
use Omnistate\Testing\RegistryContractTests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class MatchIdContractTest extends TestCase
{
use RegistryContractTests;
protected function registry(HttpClientInterface $http): object
{
return new MatchId($http);
}
protected function recordedAnswers(): array
{
return ['*' => static fn (string $method, string $url) => new MockResponse(
file_get_contents(__DIR__.'/Fixtures/'.(str_contains($url, '/id/') ? 'id.json' : 'search.json')),
)];
}
protected function civilQuery(): ?CivilQuery { return new CivilQuery(familyName: 'Chirac', givenName: 'Jacques'); }
protected function civilIdentifier(): ?string { return 'fohpDS17K0Ez'; }
}
What a test case gives
| Method | Required | Default | |
|---|---|---|---|
registry(HttpClientInterface $http): object |
yes | - | the register under test, built on the suite's client (with a fake key where it needs one) |
recordedAnswers(): array |
yes | - | the service's answers, keyed "METHOD https://host/path", "METHOD /path" (the query left out) or "*" for any other; each a JSON body as an array (HTTP 200), a MockResponse, or a callable (string $method, string $url, array $options): MockResponse |
infrastructureAnswers(): array |
no | [] |
the answers a lookup needs before the record itself (IANA's bootstrap files), kept when the suite answers HTTP 404 to the rest |
companyIdentifier(): string |
no | 901821074 |
a company the recorded answers know |
vatNumber(): string |
no | FR53901821074 |
a VAT number they know |
searchQuery(): string |
no | glitch |
what search() and professionals() are asked; the answers find something |
professionalIdentifier(): string |
no | 10003461033 |
an RPPS number they know |
facilityIdentifier(): string |
no | 750000010 |
a FINESS number they know |
civilQuery(): ?CivilQuery |
for a civil register | null |
a question it takes, the answers finding records |
civilIdentifier(): ?string |
for a civil register | null |
a record they know, by its CivilRecord::$identifier |
domainName(): string |
no | glitchr.dev |
a domain they know |
networkAddress(): string |
no | AS3333 |
an IP address or AS number they know |
The suite's client answers each request from recordedAnswers() by its method and full address,
then its method and path, then *; a request with none is answered HTTP 404, and when a test then
fails on an UnavailableException the message names every request that had no recorded answer.
The five tests
testItImplementsTheFamilysContractsUnderItsName - the register implements one of the six
contracts at least, and name() is kebab case (^[a-z0-9]+(-[a-z0-9]+)*$). A civil register
declares at least one country, each ISO alpha-2 upper case, at least one kind, as a list, a
period whose start is not after its end, and a boolean hasImages().
testWhatItDoesNotTakeIsNotSupportedBeforeAnyRequest - on a client that records every request:
a company or professional register does not support not an identifier and the facade's
company() / professional() throw NotSupportedException; a VAT register does not support
US123456789; a facility register does not support not an identifier and facility() throws;
a civil register does not support a question about AQ (Antarctica) nor one with nothing to
search on, and civilRecords() naming it throws; an Internet registry answers null for
domain('not a domain'). No request may have been made.
testUnavailableIsNeverReadAsNotFound - every question of every contract the register
implements (through the facade, but search() asked of the register itself) is asked seven
times, each with all requests failing one way: HTTP 401, 403, 429 with Retry-After: 7, 500, 503
with an HTML page, 200 with an HTML page, and a connection error. Each must be an
UnavailableException - not an answer, not another exception - and on the 429 its retryAfter
must be set and positive.
testNotFoundIsNull - with only infrastructureAnswers() kept and HTTP 404 for the rest, every
lookup (company(), professional(), facility(), civilRecord(), domain(), network()) is
null, never an exception.
testTheRecordedAnswersGiveModelsOfItsOwn - on the recorded answers, through the facade:
company() finds companyIdentifier() and search(searchQuery(), 5) finds at most five
companies, each with the register's name as source, an identifier, a name, an ISO alpha-2
country; vat() answers a check with that source and a normalized number; professional()
finds professionalIdentifier() and professionals() gives professionals with a source, an
identifier and a family name; facility() finds facilityIdentifier() with a source, an
identifier and a name; a civil register supports civilQuery(), civilRecords() finds records of
kinds it declares, with its source, an identifier and an http(s) URL when there is one, and
civilRecord(civilIdentifier()) gives back a record carrying that identifier; an Internet
registry finds domainName() (lower case) and networkAddress() with a handle.
Writing a register and running the suite: Writing a register.
Propose a change docs/testing.md
The Docker harness
docker/ runs this package with every omnistate/* source installed - from GitHub (branch 1.x),
or from the checkouts beside this one when OMNISTATE_PLUGINS=../.. is set in docker/.env -
against the real registries.
cd docker && cp .env.dist .env # nothing is needed: the registries asked are open
docker compose run --rm omnistate bare
| Command | |
|---|---|
bare |
plain PHP: Omnistate\Omnistate built by hand, a company, a VAT number and a domain asked, what PHP loaded |
bare --recorded |
the same on the answers kept in docker/harness/recorded/, without a call |
bare --json |
the same, whole: every class loaded, every file since the autoloader |
test |
every package's tests (PHPUnit; arguments after test are passed on) |
composer ..., sh, php ... |
run as they are, in /harness |
Settings
docker/.env (copied from docker/.env.dist, git-ignored) holds two variables, both optional:
| Variable | |
|---|---|
OMNISTATE_PLUGINS |
where the sources' checkouts are, relative to docker/ (../.. when this core sits in the workspace beside them); unset, docker/plugins (empty) is mounted and every source comes from GitHub |
OMNISTATE_REQUESTER |
your own VAT number, sent with bare's VAT check: VIES then answers a consultation number |
bare also reads OMNISTATE_AUTOLOAD, the autoloader to require in place of the harness's
/harness/vendor/autoload.php.
At each start the entrypoint runs docker/harness/setup.php, which writes the harness's
composer.json and phpunit.xml in the harness volume: this core from its checkout, each
source (the list is docker/harness/plugins.php) from its checkout under OMNISTATE_PLUGINS when
one is there, else from https://github.com/glitchr-omnisense/omnistate-<slug>.git, branch 1.x; one
PHPUnit suite per package. Composer installs when there is no vendor/, and updates when
composer.json changed.
Bare: no bundle, no container
One PHP script, docker/harness/bin/bare, that requires the autoloader and nothing else. It
builds Omnistate\Omnistate from the source packages installed - each registry given the HTTP
client to call with -, says which registries it has, then asks for the company 901821074
(Recherche d'entreprises), the VAT number FR53901821074 (VIES) and the domain glitchr.dev
(IANA's RDAP bootstrap, then the registry's server): four calls to open services, no key. The
registries that take a key or are asked about people (annuaire-sante, the civil registers) are
built and named, not called. Then it lists what PHP loaded and exits 1 if a class of a framework
is among it (Symfony\Component\DependencyInjection, Config, HttpKernel, HttpFoundation,
Form, Routing, Validator, a bundle, a bridge, Doctrine, Twig):
$ docker compose run --rm omnistate bare
Omnistate in bare PHP: Omnistate\Omnistate built by hand, no bundle, no container.
companies annuaire-entreprises
vat vies
internet iana
professionals annuaire-sante
civil matchid, openarchieven, national-archives-uk, nara
matchid covers FR
openarchieven covers NL BE FR SR
national-archives-uk covers GB
nara covers US
The company 901821074, asked of recherche-entreprises.api.gouv.fr:
GLITCH ART, SARL, active, created on 2021-07-03
head office 90182107400019 in ILLKIRCH-GRAFFENSTADEN, VAT FR53901821074
The domain glitchr.dev, asked of https://pubapi.registry.google/rdap/ (found through IANA's bootstrap):
registrar Gandi SAS, registered on 2022-05-19, expires on 2027-05-19
name servers ns-1-b.gandi.net, ns-210-c.gandi.net, ns-238-a.gandi.net
The VAT number FR53901821074: the registry did not answer (VIES: MS_MAX_CONCURRENT_REQ.) - that says nothing about it, ask again later.
Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, Form, Routing, Validator, a bundle, a bridge, Doctrine, Twig): none
(as answered on 2026-10-05: that day France's VIES service was refusing calls - a registry that
does not answer is said, never read as "not valid". With --recorded, the VAT number reads
valid, SARL GLITCH ART, 5 RUE VINCENT SCOTTO, 67400 ILLKIRCH GRAFFENSTADEN, as answered on
2026-09-29.)
Run again on 2026-10-25 (the same image, against the workspace's installed packages): the same
lines, France's VIES service again answering MS_MAX_CONCURRENT_REQ; with --recorded, the VAT
line valid, SARL GLITCH ART, 5 RUE VINCENT SCOTTO, 67400 ILLKIRCH GRAFFENSTADEN. Every package's
tests, the same day: OK (124 tests, 718 assertions).
Tests/BareTest.php runs bare --recorded --json in a process of its own and checks the list.
The files Composer requires together with the autoloader are not counted: they are loaded by
vendor/autoload.php itself, whatever the script does.
The image is php:8.4-cli-alpine with Composer and intl; the harness's packages live in the
harness volume of the omnistate-harness project. The sources are cloned from GitHub as plain
git repositories over HTTPS: no GitHub API (anonymous calls are rate limited), no ssh in the
image.
Propose a change docs/harness.md
Writing a register
A register is a package of its own (omnistate/<service>, namespace Omnistate\<Service>\) that
requires omnisense/omnistate and symfony/http-client: one class, built on an
HttpClientInterface (and a key where the service wants one), implementing one or more of the
family's contracts:
| Contract | Asks | Facade |
|---|---|---|
Registry\CompanyRegistryInterface |
supports($identifier), company($identifier), search($query, $limit) |
company(), search() |
Registry\VatRegistryInterface |
supports($number), check($number, $requester) |
vat() |
Registry\ProfessionalRegistryInterface |
supports($identifier), professional($identifier), search($name, $postcode, $limit) |
professional(), professionals() |
Registry\FacilityRegistryInterface |
supportsFacility($identifier), facility($identifier) |
facility() |
Registry\CivilRegistryInterface |
countries(), kinds(), period(), hasImages(), supports($query), search($query), find($identifier) |
civilRecords(), civilRecord() |
Registry\InternetRegistryInterface |
domain($name), network($address) |
domain(), network() |
name() is a short name (annuaire-entreprises), the $source of every model it returns.
Identifier rules (SIREN, SIRET, VAT numbers, RPPS, FINESS) live in Omnistate\Identifier\: a
register checks the form there and asks nothing about an identifier that is not one.
Errors: the family's rule
Not knowing is an answer; not answering is not. A register never reads one as the other:
| What happens | What the register does |
|---|---|
| it does not know the company, the professional, the record, the domain (HTTP 404, 410, an empty result) | null from a lookup, [] from a search |
| the identifier or the question is not one for it | supports() says no; the facade throws NotSupportedException without asking |
| HTTP 401 or 403 (a key missing or refused, a firewall) | UnavailableException |
| HTTP 429 | UnavailableException with $retryAfter in seconds: the answer's Retry-After when it gives one, else the register's own estimate (NARA: until its monthly quota renews) |
| HTTP 5xx, no answer (DNS, connection, timeout), an answer that cannot be read (an HTML page in place of JSON) | UnavailableException |
| a question the service refuses as malformed (HTTP 400) | null/[] or an OmnistateException with the service's reason, as the register documents |
A VAT check that cannot be made is an UnavailableException, never a VatCheck saying "not valid".
Conformance suite
Omnistate\Testing\RegistryContractTests is the family's contract as PHPUnit tests, offline. Every
register's Tests/<Service>ContractTest.php uses it:
use Omnistate\Testing\RegistryContractTests;
final class MatchIdContractTest extends TestCase
{
use RegistryContractTests;
protected function registry(HttpClientInterface $http): object
{
return new MatchId($http);
}
protected function recordedAnswers(): array
{
return ['*' => static fn (string $method, string $url) => new MockResponse(
file_get_contents(__DIR__.'/Fixtures/'.(str_contains($url, '/id/') ? 'id.json' : 'search.json')),
)];
}
protected function civilQuery(): ?CivilQuery { return new CivilQuery(familyName: 'Chirac', givenName: 'Jacques'); }
protected function civilIdentifier(): ?string { return 'fohpDS17K0Ez'; }
}
The register is built on the suite's MockHttpClient; recordedAnswers() gives the service's
answers by "METHOD https://host/path", "METHOD /path" or "*" (a JSON body as an array, a
MockResponse, or a callable). A request with no recorded answer is answered HTTP 404, and the
failure names it. Hooks name what the recorded answers know: companyIdentifier(),
vatNumber(), searchQuery(), professionalIdentifier(), facilityIdentifier(),
civilQuery(), civilIdentifier(), domainName(), networkAddress(); infrastructureAnswers()
keeps what a lookup needs before the record itself (IANA's bootstrap files) when the suite answers
HTTP 404 to the rest.
What it checks, for each contract the register implements, asked through the Omnistate facade:
- it implements one of the contracts at least, under a short name; a civil register's countries are ISO 3166-1 alpha-2, its kinds and period well formed;
- an identifier of another kind, a civil question about another country or with nothing to search
on, is not supported:
NotSupportedExceptionfrom the facade, and no HTTP request; - HTTP 401, 403, 429, 500, 503 (an HTML page), HTTP 200 with an HTML page, and no answer at all are
an
UnavailableExceptionfor every question - nevernull,[]or aVatCheck; a 429's carries$retryAfter; - HTTP 404 on every lookup is
null; - on the recorded answers, every lookup and search gives models carrying the register's name as
$source, their identifier and name; a civil record is of a kind the register holds, andfind()gives back the record a search named.
Every hook, and what each of the five tests checks: Testing.
Run it with the register's other tests: in the workspace (~/Sites/omnistate, path repositories
and one phpunit.xml.dist), or through the Docker harness (harness): add the
package to docker/harness/plugins.php (its slug, its tests' namespace, its class and the
facade's argument it is given as) for the harness to install it and run its tests.
Wiring it
- Plain PHP: give it to the facade under its kind (
companies:,vat:,professionals:,civil:,internet:). - Symfony: an application's register is autoconfigured by its contract
(Symfony). A package published as
omnistate/<service>is registered by the bundle only onceOmnistateBundle::loadExtension()knows its class: until then, the application defines it as a service (autoconfiguration tags it).
Propose a change docs/providers.md
Verified, and not
What the tests prove
Run on 2026-10-25 in the harness image (PHP 8.4.26, PHPUnit 11.5), on the workspace
that installs the core and the eight registers from their checkouts: OK (124 tests, 718 assertions). All offline:
- the core: the facade's routing by kind of identifier and its cache (
Tests/OmnistateTest.php), the identifiers (Tests/Identifier/IdentifierTest.php), professionals and civil records through the facade (Tests/ProfessionalTest.php,Tests/CivilTest.php), the bundle's wiring and constraints (Tests/Bridge/OmnistateBundleTest.php), the company search, its endpoint and its field (Tests/Bridge/CompanySearchTest.php), and no class of a framework loaded in plain PHP (Tests/BareTest.php, which runsbare --recorded --jsonin a process of its own); - each register: its own tests on the service's recorded answers (
Tests/Fixtures), and the conformance suiteRegistryContractTests(Testing), five tests each.
What was run live
On 2026-10-25, from the harness, against the services (every example of this manual marked "run on 2026-10-25" pastes that output):
- Recherche d'entreprises: a company by SIREN, by SIRET, a search by name;
- VIES: five numbers (two valid, three refused for their shape without a call), and France's
service answering
MS_MAX_CONCURRENT_REQfor another number minutes earlier; - IANA's RDAP bootstrap, then Verisign's and the RIPE NCC's RDAP servers: a domain, an IP address, an AS number;
- matchID, Open Archives and Discovery: the civil registers' example.
The registers' manuals state their own earlier live runs (matchID, Open Archives and Discovery on 2026-10-04, NARA with a key on 2026-10-10).
Not verified
- The Annuaire Santé was not called: no key at hand. Its examples run on the ANS's recorded answers.
- NARA was not called on 2026-10-25: no key at hand.
- The Laravel page was not run in a Laravel application.
- The Symfony bundle was not run in a Symfony application here: its tree and its services were
read from a container built by hand in the harness (
OmnistateBundle's extension loaded and compiled), and its tests run in the suite above.
Defects found in the code
None left: those the manuals found were fixed on 2026-10-29.
In the documentation, corrected here: docs/civil.md said there was no NARA package "until a key
is at hand to verify one", while omnistate/nara exists and the bundle registers it.