Skip to content

omnisense/omnistate

Omnistate: one contract for what states and registries know - companies, VAT numbers, domains and networks (the Omnipay of public registers).

Floor
Family
Version
1.x
Verified for real
Its README does not say
Licence
MIT
composer require omnisense/omnistate:1.x-dev

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

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/omnistate requires symfony/http-client-contracts (^3.0) and nothing else.
  • Each register requires omnisense/omnistate and symfony/http-client (^6.4, ^7.0 or ^8.0), a library that stands alone and loads no framework.
  • omnistate/iana suggests ext-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/cache provides 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, Rdap with its Bootstrap, AnnuaireSante, MatchId, OpenArchieven, NationalArchivesUk, Nara), each given the HTTP client to call with - the application's, a MockHttpClient in 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 as requester (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\OmnistateBundle registers every source installed on the application's http_client, autowires Omnistate\Omnistate with 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 Omnistate once, 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.

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:

  • NotSupportedException when 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., or The 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() and professionals() never throw it: with no register installed they answer [].
  • Whatever the register throws: UnavailableException when it did not answer, sometimes an OmnistateException when 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 (retryAfter from a 429's Retry-After, else 60 s).
  • An RDAP server answering HTTP 401, 403, 429 (retryAfter from its Retry-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: an OmnistateException with 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's limit - 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 a NotSupportedException, 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() reads 19321129, 1932-11, 29/11/1932, 19320000.
  • Period: year(), years(), since(), around(), day(), contains().
  • Place, Archive, the enums CivilRecordKind (with gedcom(): BIRT, CHR, MARR, DEAT, BURI) and Sex.
  • Identifier\CountryCode: alpha2('FRA') is FR. 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/nara is 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() and Rpps::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 checks supports() first and throws NotSupportedException instead).
  • 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:

  • UnavailableException is 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 VatCheck with valid false and source format, 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\ArrayAdapter and 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: NotSupportedException from 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 UnavailableException for every question - never null, [] or a VatCheck; 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, and find() 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 once OmnistateBundle::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 runs bare --recorded --json in a process of its own);
  • each register: its own tests on the service's recorded answers (Tests/Fixtures), and the conformance suite RegistryContractTests (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_REQ for 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.

Propose a change docs/verified.md