Omnilex is one PHP contract for reading the sources of law - texts, articles and court decisions - from the services that publish them: Légifrance (French codes, laws, decrees, the Journal officiel), Judilibre (the decisions of the French judicial courts), EUR-Lex (EU law and the case law of the Court of Justice) and the open data of the French administrative courts. The application asks a source by name for what it does and reads neutral models:
$article = $registry->articles('eurlex')->article('32016R0679', '17', new DateTimeImmutable('2018-06-01'));
$article->title; // Droit à l'effacement («droit à l'oubli»)
$article->version->id; // 02016R0679-20160504: the version in force that day
$registry->decisions('administratif')->decision('ECLI:FR:CECHS:2026:505010.20261002');
$registry->search('judilibre')->search(new Query(text: 'expropriation', jurisdictions: ['cc'], from: '2020-01-01'));
$registry->recent('legifrance')->recent(new DateTimeImmutable('-7 days')); // what a legal watch reads
Each service is a provider package (omnilex/legifrance, omnilex/judilibre, omnilex/eurlex,
omnilex/justice-administrative) with a factory that builds a source. A source implements the
capability interfaces of what its service does, and only those (search, text, article, decision,
citations, recent); its Capabilities say what it holds, which identifiers it reads and which
search criteria it applies. Identifiers (ECLI, CELEX, ELI, NOR, numéro de pourvoi, Légifrance's
own) are normalised and validated without the network. Texts and articles are read as in force
at a date. "Not found" is null; a service down, refusing the credentials or asking to slow
down is an exception, never read as "not found"; a criterion a source cannot apply is refused,
not dropped. The core requires PHP 8.2 and symfony/http-client-contracts, nothing of a
framework; it ships a Symfony bundle.
When to use it
- A site or a back office that quotes an article, a text or a decision, with its version and its link to the official page.
- A legal watch: what was published or decided since a day, per source, without mistaking a service that is down for "nothing new".
- Code that reads an identifier given by a user (an ECLI, a CELEX number, a Légifrance page's
address) and finds the source that reads it (
Registry::reading()).
It is not a legal database: nothing is stored here. Consolidated versions are documentary (only the Journal officiel and the Official Journal of the EU are authentic), and decisions published pseudonymised are given as they are - no field holds the parties, and nothing here tries to put a name back.
Providers
Four packages. Operations are the interfaces each source implements; read from the code at this commit.
| Package | Factory name | Holds | Operations | Access | Called live |
|---|---|---|---|---|---|
omnilex/legifrance |
legifrance |
texts, articles, decisions (FR) | search, text, article, decision, citations, recent | PISTE, OAuth client credentials | no (no credentials) |
omnilex/judilibre |
judilibre |
decisions of the judicial courts (FR) | search, decision, citations, recent | PISTE, OAuth or API key | no (no credentials) |
omnilex/eurlex |
eurlex |
texts, articles, decisions (EU) | search, text, article, decision, citations, recent | open | yes, 2026-10-26 |
omnilex/justice-administrative |
justice-administrative |
decisions of the administrative courts (FR) | search, decision, recent | open | yes, 2026-10-26 |
What each reads, filters and allows: Sources and search.
How this manual is organised
- Installation: requirements, a first script in plain PHP and its output.
- Symfony: the bundle, its configuration tree, its services.
- Laravel: a service provider written by hand (there is no Laravel bridge).
- Concepts and models: the contract interface by interface, every model, field and enumeration.
- Identifiers: the schemes, their normalisation, what an identifier tells.
- Usage: sources and search (what each source does, the criteria, a legal watch), the law at a date (versions), authentication (PISTE).
- Configuration reference: the registry, every option of every factory.
- Errors: the exceptions, the HTTP statuses behind them, what to catch.
- Testing: testing code that uses the family, the family's own tests.
- The Docker harness: the console, bare PHP, the tests.
- Writing a provider.
- Verified, and not.
Proposer une modification docs/index.md
Installation
composer require omnisense/omnilex omnilex/eurlex omnilex/justice-administrative
composer require omnilex/legifrance omnilex/judilibre # with a PISTE account
Requirements
- PHP 8.2 or later.
omnisense/omnilexrequiressymfony/http-client-contracts(^3.0) and nothing else: no framework, no HTTP client of its own. It replacesomniscient/omnilex: acomposer.jsonthat still requires the old name resolves to this package.- Each provider requires
omnisense/omnilexandsymfony/http-client(6.4, 7 or 8). Its factory takes anHttpClientInterface(the application's, aMockHttpClientin a test) and makes its own when given none (HttpClient::create()with a timeout of 60 seconds; 30 for Judilibre). - The Symfony bundle needs
symfony/config,symfony/dependency-injection,symfony/http-kernel, andpsr/cachewith a pool (symfony/cache) to keep the PISTE tokens; none is required by the package (Symfony).
It runs the same in plain PHP, in a worker, in Laravel or Slim, and in Symfony.
Plain PHP
<?php // bare.php
require __DIR__.'/vendor/autoload.php';
use Omnilex\Eurlex\EurlexSourceFactory;
use Omnilex\Registry;
use Symfony\Component\HttpClient\HttpClient;
$http = HttpClient::create();
$registry = new Registry([new EurlexSourceFactory($http)], [
'eurlex' => ['factory' => 'eurlex', 'options' => ['language' => 'fr']],
]);
// Article 17 of the General Data Protection Regulation, as in force on 1 June 2018
$article = $registry->articles('eurlex')->article('32016R0679', '17', new DateTimeImmutable('2018-06-01'));
echo $article->textTitle, "\n\n";
echo 'Article ', $article->number, ' - ', $article->title, "\n";
echo 'version ', $article->version->id, ', from ', $article->version->from->format('Y-m-d'), ', ', $article->version->status->value, "\n\n";
echo strtok($article->content, "\n"), "\n";
$ php bare.php
Règlement (UE) 2016/679 du Parlement européen et du Conseil du 27 avril 2016 relatif à la protection des personnes physiques à l'égard du traitement des données à caractère personnel et à la libre circulation de ces données, et abrogeant la directive 95/46/CE (règlement général sur la protection des données) (Texte présentant de l'intérêt pour l'EEE)
Article 17 - Droit à l'effacement («droit à l'oubli»)
version 02016R0679-20160504, from 2016-05-04, in_force
1. La personne concernée a le droit d'obtenir du responsable du traitement l'effacement, dans les meilleurs délais, de données à caractère personnel la concernant et le responsable du traitement a l'obligation d'effacer ces données à caractère personnel dans les meilleurs délais, lorsque l'un des motifs suivants s'applique:
(run on 2026-10-26 with the harness's image and the workspace's packages, against the Publications Office's live endpoint; the same answer as on 2026-10-05)
No key, no account: the script asks the Publications Office's open endpoint. That is all there is to it:
- a factory per source package (
EurlexSourceFactory,JusticeAdministrativeSourceFactory,LegifranceSourceFactory,JudilibreSourceFactory), which takes the HTTP client to call with - the application's, aMockHttpClientin a test; with none given it makes its own (HttpClient::create()) - and, for the sources behind PISTE, a token store (authentication); - the registry, built by hand from the factories and the sources' options, by name;
- the sources it gives, each as what it does:
articles(),texts(),decisions(),search(),citations(),recent().
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 omnilex bare
(harness).
One source
use Omnilex\Eurlex\EurlexSourceFactory;
use Omnilex\Model\Kind;
use Omnilex\Model\Query;
use Symfony\Component\HttpClient\HttpClient;
$eurlex = (new EurlexSourceFactory(HttpClient::create()))->create(['language' => 'fr']);
$text = $eurlex->text('32016R0679', new DateTimeImmutable('2018-06-01'));
$text->title; // Règlement (UE) 2016/679 ...
$text->version->id; // 02016R0679-20160504
$text->isInForce(); // true
$article = $eurlex->article('32016R0679', '17');
$article->title; // Droit à l'effacement («droit à l'oubli»)
$results = $eurlex->search(new Query(text: 'protection des données', kind: Kind::TEXT, types: ['REG'], from: '2016-01-01'));
foreach ($results as $reference) {
echo $reference->date?->format('Y-m-d'), ' ', $reference->id, ' ', $reference->title, "\n";
}
$next = $eurlex->search(new Query(text: 'protection des données', cursor: $results->next)); // the next page
A document is named by an Identifier or a string the source reads: a normalised identifier
('32016R0679', 'ECLI:EU:C:2014:317', 'JUSC1732516D', 'LEGIARTI000033219357'), the URL of
its page, or the source's own identifier - the id its search gave.
Several sources
use Omnilex\Registry;
$registry = new Registry(
[new EurlexSourceFactory($http), new JusticeAdministrativeSourceFactory($http), new LegifranceSourceFactory($http)],
[
'eurlex' => ['factory' => 'eurlex', 'options' => ['language' => 'fr']],
'administratif' => ['factory' => 'justice-administrative'],
'legifrance' => ['factory' => 'legifrance', 'options' => ['client_id' => '...', 'client_secret' => '...']],
],
);
$registry->decisions('eurlex')->decision('ECLI:EU:C:2014:317'); // typed: a DecisionReaderInterface
$registry->recent('administratif')->recent(new DateTimeImmutable('-7 days'));
$registry->having('recent'); // the sources a watch can ask
$registry->reading(Scheme::ECLI); // the sources that read an ECLI
Nothing is built before a source is asked for: a registry whose PISTE credentials are missing
still works for the other sources. having(), reading() and usable() leave out the sources
whose options are incomplete; get() and all() refuse them (InvalidConfigException).
In a framework
- Symfony:
Omnilex\Bridge\Symfony\OmnilexBundleregisters the factories on the application'shttp_client, keeps the PISTE tokens incache.app, builds the registry fromconfig/packages/omnilex.yamland makes each source injectable by its name, as what it does: see Symfony. Its components (symfony/config,symfony/dependency-injection,symfony/http-kernel, a cache pool) are not required by this package: a Symfony application has them. - Any other: build the
Registryonce, where the framework builds its services (a service provider, a container definition), as the script above does.
A legal watch
foreach ($registry->having('recent') as $name => $source) {
try {
$new = $source->recent($lastRun, new Query(kind: Kind::DECISION));
} catch (UnavailableException $e) {
continue; // down, refused or slowed: try again later - not "nothing new"
}
// store $new->items for validation; follow $new->next for the pages after
}
Each source says which date recent() reads: the day a decision was added to the base
(administrative courts, Judilibre), the day a text was published (Légifrance), the date of the
document (EUR-Lex).
Errors
Not found is null or an empty page; everything else is an exception implementing
Omnilex\Exception\OmnilexException. A service that is down, refuses the credentials or asks to
slow down is an UnavailableException (or one of its children), never "nothing": see
Errors.
Rate limits
Every source takes a throttle option: the seconds left between two calls (0.5 by default, 1
for the administrative courts' open data). The PISTE quotas are per application and shown on
the portal; a quota spent comes back as a RateLimitedException. Cache what the sources answer
- a version of a text that is no longer in force never changes - but never an
UnavailableException.
Proposer une modification docs/installation.md
Symfony
Omnilex runs without a framework (Installation); in a Symfony application its
bundle does the wiring. The bundle lives in this package (Omnilex\Bridge\Symfony), not in
omnibridge/symfony. Its components - symfony/config, symfony/dependency-injection,
symfony/http-kernel (6.4, 7 or 8), and a PSR-6 cache pool for the tokens - are not required by
omnisense/omnilex: 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 [
// ...
Omnilex\Bridge\Symfony\OmnilexBundle::class => ['all' => true],
];
Configuration
# config/packages/omnilex.yaml
omnilex:
sources: # by name: a factory and its options
legifrance: { factory: legifrance, options: { client_id: '%env(default::PISTE_CLIENT_ID)%', client_secret: '%env(default::PISTE_CLIENT_SECRET)%' } }
judilibre: { factory: judilibre, options: { client_id: '%env(default::PISTE_CLIENT_ID)%', client_secret: '%env(default::PISTE_CLIENT_SECRET)%' } }
eurlex: { factory: eurlex, options: { language: fr } }
administratif: { factory: justice-administrative }
token_cache: cache.app # where the PISTE tokens are kept; null: in memory only
# .env.local, or bin/console secrets:set
PISTE_CLIENT_ID=...
PISTE_CLIENT_SECRET=...
The whole tree, from OmnilexBundle::configure():
| Key | Type | Default | |
|---|---|---|---|
omnilex.sources |
map, keyed by the source's name | {} |
the sources the application uses |
omnilex.sources.<name>.factory |
string, required, not empty | - | a factory's name: legifrance, judilibre, eurlex, justice-administrative, or an application's own factory's getName() |
omnilex.sources.<name>.options |
any value (a map) | [] |
handed as they are to the factory's create() (Configuration) |
omnilex.token_cache |
string or null |
cache.app |
the id of a PSR-6 pool (Psr\Cache\CacheItemPoolInterface) that keeps the PISTE tokens between two requests; null: each PHP process asks for its own token |
The options are not validated when the container compiles but when the source is built: a
credential left empty shows only then, as an InvalidConfigException. Use
'%env(default::PISTE_CLIENT_ID)%' so that the variable may be absent: the site still boots,
and Registry::usable() leaves the source out. A boolean option may be a plain %env()%: "false"
and "0" read as false (Configuration).
Services
| Service id | Class | Autowiring | |
|---|---|---|---|
Omnilex\Registry |
Registry |
Registry $registry |
every configured source by name. Public. |
omnilex.source.<name> |
SourceInterface |
SourceInterface $<name>, SearchInterface $<name>, TextReaderInterface $<name>, ArticleReaderInterface $<name>, DecisionReaderInterface $<name>, CitationsInterface $<name>, RecentInterface $<name> |
one source, built by Registry::get('<name>') the first time it is injected |
Omnilex\Bridge\Symfony\CacheTokenStore |
CacheTokenStore |
TokenStoreInterface (alias) |
the tokens in the token_cache pool, each until it dies; registered when token_cache is set and psr/cache is installed |
Omnilex\Legifrance\LegifranceSourceFactory, ...\Judilibre\JudilibreSourceFactory, ...\Eurlex\EurlexSourceFactory, ...\JusticeAdministrative\JusticeAdministrativeSourceFactory |
the provider's factory | by its class | registered when its package is installed; given the http_client service (or null when the application has none) and the token store; tagged omnilex.source_factory |
use Omnilex\Exception\UnavailableException;
use Omnilex\Model\Query;
use Omnilex\Source\RecentInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class WatchController extends AbstractController
{
#[Route('/watch/conseil-etat')]
public function __invoke(RecentInterface $administratif): Response
{
try {
$decisions = $administratif->recent(new \DateTimeImmutable('-7 days'), new Query(jurisdictions: ['CE']));
} catch (UnavailableException) {
return $this->render('watch/unavailable.html.twig', status: 503);
}
return $this->render('watch/index.html.twig', ['decisions' => $decisions]);
}
}
The argument's name selects the source (Symfony's named autowiring aliases); its type the
interface. An alias is registered under each of the seven interfaces for every source, whatever
it implements: a source injected as something it does not do fails where it is injected (PHP's
TypeError when the service is built). Inject SourceInterface or the Registry when in doubt,
and ask Registry::articles('<name>'), which throws a NotSupportedException that names the
operation.
Nothing is built when the container compiles: a source is built the first time it is asked for.
An application's own source
A class of the application that implements Omnilex\Source\SourceFactoryInterface (usually by
extending Omnilex\Source\SourceFactory) is autoconfigured: tagged omnilex.source_factory, it
joins the registry, and its getName() can be named as a factory (Writing a
provider). It is autowired as any service of the application; the bundle gives the
HTTP client and the token store to its own four factories only.
The bundle registers no command, event, Twig function or route. Cache what the sources answer
(they are remote, and PISTE counts the calls) - never an UnavailableException.
Tests/Bridge/OmnilexBundleTest.php boots a kernel with this bundle alone (an http_client
MockHttpClient, a cache.app ArrayAdapter, two autoconfigured factories of the test's own):
the sources injected by name as what they do, and a PISTE token kept in the application's cache.
Proposer une modification docs/symfony.md
Laravel
omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnilex provider. None is needed: the registry is plain PHP, and a service provider of the application builds it once, from a config file. The one piece to write is a token store over Laravel's cache, so that the PISTE token is shared by the requests for the hour it lives.
// config/omnilex.php
return [
'sources' => [
'legifrance' => ['factory' => 'legifrance', 'options' => [
'client_id' => env('PISTE_CLIENT_ID'),
'client_secret' => env('PISTE_CLIENT_SECRET'),
'sandbox' => (bool) env('PISTE_SANDBOX', false), // env() reads "false" as false
]],
'eurlex' => ['factory' => 'eurlex', 'options' => ['language' => 'fr']],
'administratif' => ['factory' => 'justice-administrative'],
],
];
// app/Omnilex/LaravelTokenStore.php
namespace App\Omnilex;
use Illuminate\Contracts\Cache\Repository;
use Omnilex\Auth\TokenStoreInterface;
use Omnilex\Model\Token;
final class LaravelTokenStore implements TokenStoreInterface
{
public function __construct(private readonly Repository $cache)
{
}
public function get(string $key): ?Token
{
$data = $this->cache->get($key);
return \is_array($data) && isset($data['access_token']) ? Token::fromArray($data) : null;
}
public function set(string $key, Token $token): void
{
$this->cache->put($key, $token->toArray(), $token->expiresAt); // null: kept until deleted
}
public function delete(string $key): void
{
$this->cache->forget($key);
}
}
// app/Providers/OmnilexServiceProvider.php
namespace App\Providers;
use App\Omnilex\LaravelTokenStore;
use Illuminate\Support\ServiceProvider;
use Omnilex\Eurlex\EurlexSourceFactory;
use Omnilex\JusticeAdministrative\JusticeAdministrativeSourceFactory;
use Omnilex\Legifrance\LegifranceSourceFactory;
use Omnilex\Registry;
use Symfony\Component\HttpClient\HttpClient;
final class OmnilexServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Registry::class, function ($app): Registry {
$http = HttpClient::create(['timeout' => 60]);
$tokens = new LaravelTokenStore($app['cache.store']);
return new Registry([
new LegifranceSourceFactory($http, $tokens),
new EurlexSourceFactory($http),
new JusticeAdministrativeSourceFactory($http),
], config('omnilex.sources'));
});
}
}
Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject the
registry and ask it for what is needed ($registry->articles('legifrance'),
$registry->having('recent')).
The HTTP client is symfony/http-client's (every provider requires it). List in the provider
every factory the application uses: a hand-built registry knows only the factories it is given.
This page was written from the contract's API; it was not run in a Laravel application.
Proposer une modification docs/laravel.md
Concepts and models
The contract
A source is one service, configured. It implements Omnilex\Source\SourceInterface and the
capability interfaces of what its service does, and only those; each extends SourceInterface.
| Interface | Method | Answers |
|---|---|---|
SourceInterface |
getName(): string |
the name it is configured as: legifrance, judilibre, eurlex, justice-administrative |
capabilities(): Capabilities |
what it holds, reads and filters by | |
SearchInterface |
search(Query $query): Results |
one page of References matching the query |
TextReaderInterface |
text(Identifier|string $id, ?DateTimeInterface $at = null): ?Text |
the text as in force that day (today when null), or null when unknown or without a version that day |
ArticleReaderInterface |
article(Identifier|string $id, ?string $number = null, ?DateTimeInterface $at = null): ?Article |
one article as in force that day; $id names the article itself (then no $number) or its text, with the article's $number; without a date, the version the identifier names when it names one, else today's |
DecisionReaderInterface |
decision(Identifier|string $id): ?Decision |
the decision, or null |
CitationsInterface |
citations(Identifier|string $id, int $limit = 100): list<Citation> |
the links of a document both ways, at most $limit each way, newest first where the source dates them; empty when unknown |
RecentInterface |
recent(DateTimeInterface $since, ?Query $query = null): Results |
what is new since that day, newest first, a page at a time; the query narrows it (its from, to and sort are not read) |
Every reader declares @throws NotSupportedException (an identifier or a criterion the source
cannot take), UnavailableException (down, refused, slowed) and ProviderException (any other
error answer). recent() never answers an empty page for a service that is down: a watch must
not read "down" as "nothing new".
A document is named by an Identifier or a string the source reads: a normalised identifier
('32016R0679', 'ECLI:EU:C:2014:317', 'JUSC1732516D', 'LEGIARTI000033219357'), the URL of
its page, or the source's own identifier - the id its search gave (Identifiers).
A factory (Omnilex\Source\SourceFactoryInterface) builds a source from options:
| Method | |
|---|---|
getName(): string |
the name sources are configured with |
create(array $options = []): SourceInterface |
a source; InvalidConfigException when an option is missing |
Omnilex\Source\SourceFactory is the abstract base every provider extends, Omnilex\Source\HttpSource
the base of every source over HTTP (Writing a provider), Omnilex\Registry the
sources by name (Configuration).
Models
All models are final readonly classes in Omnilex\Model, built with named arguments. Dates are
DateTimeImmutable at midnight UTC: dates of law have no hour. Every document model carries
source (the source's name) and raw (what the service answered, for what the model does not
carry; the full texts removed from it).
Text
A code, a law, a decree, a regulation, a directive - in one version: the one in force at the date asked for.
| Property | Type | Default | |
|---|---|---|---|
id |
string | required | the source's identifier: what text() reads again |
title |
string | required | |
identifiers |
Identifiers |
empty | CELEX, ELI, NOR, LEGITEXT, JORFTEXT |
type |
?string | null |
the source's own nature: CODE, LOI, DECRET, REG, DIR... |
date |
?DateTimeImmutable | null |
the date of the text: its signature, its adoption |
publishedOn |
?DateTimeImmutable | null |
its publication |
version |
?Version | null |
the version read: the one of the day asked for |
versions |
list of Version |
[] |
every version the source lists, oldest first (empty when it lists none) |
content |
?string | null |
plain text of the whole version, when the source gives it |
html |
?string | null |
the same as published |
articles |
list of Article |
[] |
in the text's order, when the source gives them apart |
subjects |
list of strings | [] |
|
citations |
list of Citation |
[] |
|
language |
?string | null |
ISO 639-1 |
url |
?string | null |
the page that shows it |
source, raw |
string, array | '', [] |
isInForce(): bool- its version's status isIN_FORCE.article(string $number): ?Article- the article of that number among those given, whatever the spelling of the number.Text::number(string $number): string- a number as compared:L. 36-11,L36-11andl 36 11all becomeL3611.
Article
One article, in one version.
| Property | Type | Default | |
|---|---|---|---|
id |
string | required | what article() reads again (a LEGIARTI, EUR-Lex's <celex>#art_<n>) |
number |
?string | required | 17, L36-11 |
content |
string | required | plain text |
html |
?string | null |
as published |
title |
?string | null |
the article's heading, where the source has one (EUR-Lex) |
identifiers |
Identifiers |
empty | |
version |
?Version | null |
the version read |
versions |
list of Version |
[] |
every version of the article the source knows, oldest first |
textId, textTitle |
?string | null |
the text it belongs to |
path |
?string | null |
the headings above it: Partie législative > Livre Ier > Titre II |
note |
?string | null |
the nota |
citations |
list of Citation |
[] |
what amended it, what it cites |
url |
?string | null |
|
source, raw |
string, array | '', [] |
isInForce(): bool.
Version
One version of a text or of an article (The law at a date).
| Property | Type | Default | |
|---|---|---|---|
from |
?DateTimeImmutable | null |
the first day it applies |
to |
?DateTimeImmutable | null |
its last day; null when no end is known |
status |
Status |
UNKNOWN |
its legal state |
id |
?string | null |
the identifier that reads that version again |
label |
?string | null |
the source's name for it (Official Journal, consolidated 2016-05-04, Légifrance's version number) |
url |
?string | null |
covers(DateTimeInterface $date): bool- whether it applies that day; a version with an unknownfromnever does.Version::at(iterable $versions, DateTimeInterface $date): ?Version- among several, the one that applies that day; when two do (one ends the day the next starts), the laterfromwins.
Status (string-backed):
| Case | Value | |
|---|---|---|
IN_FORCE |
in_force |
|
FUTURE |
future |
adopted, comes into force later |
MODIFIED |
modified |
replaced by a later version |
REPEALED |
repealed |
|
ANNULLED |
annulled |
|
EXPIRED |
expired |
no longer applicable without having been repealed (lapsed, transferred, replaced) |
UNKNOWN |
unknown |
isInForce(): bool - IN_FORCE only.
Decision
A court's decision.
| Property | Type | Default | |
|---|---|---|---|
id |
string | required | what decision() reads again |
title |
?string | null |
|
identifiers |
Identifiers |
empty | ECLI, numéros de pourvoi, JURITEXT, CELEX |
court |
?Court | null |
|
date |
?DateTimeImmutable | null |
the day it was given |
number |
?string | null |
the main case number: pourvoi, requête, affaire |
numbers |
list of strings | [] |
every case number, the main one first |
type |
?string | null |
the source's own nature: arret, Ordonnance, JUDG... |
solution |
?string | null |
the source's words |
publication |
?string | null |
how the court published it: bulletin, recueil Lebon... |
summary |
?string | null |
sommaire |
subjects |
list of strings | [] |
the headings the court filed it under (titrage, themes) |
content |
?string | null |
plain text |
html |
?string | null |
|
citations |
list of Citation |
[] |
texts applied, decision contested, case law brought together |
pseudonymised |
?bool | null |
true when the source publishes it with the names of the persons removed; null when it does not say |
updatedOn |
?DateTimeImmutable | null |
when the source last changed it |
language |
?string | null |
ISO 639-1 |
url |
?string | null |
|
source, raw |
string, array | '', [] |
ecli(): ?string - the first ECLI of identifiers. There is no property for the parties: a
pseudonymised decision is given as it is, and a site that shows it must not put the names back.
Court
| Property | Type | Default | |
|---|---|---|---|
name |
string | required | Cour de cassation, Conseil d'État, Cour de justice |
code |
?string | null |
the source's own key (cc, CE, TA75, CJ): what Query::$jurisdictions takes for that source |
country |
?string | null |
FR, EU |
chamber |
?string | null |
|
formation |
?string | null |
|
location |
?string | null |
the seat, for the courts that have several: Cour d'appel de Nancy |
Reference and Results
A Reference is what a search lists and what a citation leads to:
| Property | Type | Default | |
|---|---|---|---|
kind |
Kind |
required | |
id |
string | required | the source's own identifier: what its text(), article() or decision() reads |
title |
string | required | |
identifiers |
Identifiers |
empty | the normalised ones |
date |
?DateTimeImmutable | null |
|
type |
?string | null |
the source's own nature |
number |
?string | null |
|
court |
?Court | null |
|
status |
?Status | null |
|
summary |
?string | null |
an excerpt or a summary, plain text |
url |
?string | null |
|
source, raw |
string, array | '', [] |
Results is one page, iterable and countable:
| Property | Type | Default | |
|---|---|---|---|
items |
list of Reference |
[] |
|
next |
?string | null |
the cursor of the next page (pass it as Query::$cursor); null on the last page or when the source does not page |
total |
?int | null |
how many documents the whole query matches, when the source says |
isLast(): bool - next is null.
Kind (string-backed): TEXT (text: a whole text), ARTICLE (article), DECISION
(decision).
Citation and Relation
| Property | Type | Default | |
|---|---|---|---|
relation |
Relation |
required | read from the document asked for |
target |
Reference |
required | where it leads |
note |
?string | null |
what the source says of the link: its own type, the provision concerned |
Relation (string-backed): CITES / CITED_BY, APPLIES / APPLIED_BY (a decision applies a
text, its visa), INTERPRETS / INTERPRETED_BY (a preliminary ruling), AMENDS / AMENDED_BY,
REPEALS / REPEALED_BY, BASED_ON / BASIS_OF (a text adopted on the basis of another),
CONTESTS (the judgment under appeal), FOLLOWED_BY (the decision that came after in the same
case), RELATED (case law brought together by the court itself). Values are the lower-case names
(cited_by).
Query
What to ask search() and recent() for. Every criterion is optional.
| Parameter | Type | Default | |
|---|---|---|---|
text |
?string | null |
free text; in double quotes, the exact expression where the source can |
title |
?string | null |
words of the title |
number |
?string | null |
of a text (2019-290), of an article (L36-11), of a case (17-18.194) |
kind |
?Kind | null |
|
jurisdictions |
list of strings | [] |
the source's own codes: a court, an order of courts |
from |
DateTimeInterface, string or null |
null |
dated from that day (a decision's date, a text's signature) |
to |
the same | null |
dated up to that day |
subjects |
list of strings | [] |
the source's own subject headings |
types |
list of strings | [] |
the source's own natures: LOI, DECRET, arret, REG... |
at |
DateTimeInterface, string or null |
null |
texts and articles as in force that day |
limit |
int | 25 |
results per page (each source caps it) |
cursor |
?string | null |
the Results::$next of the page before |
sort |
string | Query::RELEVANCE |
relevance, newest (Query::NEWEST) or oldest (Query::OLDEST): a preference - a source that cannot sort that way keeps its own order |
from, to and at are read with Query::day(): a string PHP reads as a date, or a
DateTimeInterface, made midnight UTC of that day; a string that is not a date is an
InvalidArgumentException ("x" is not a date.).
with(array $changes): Query- a copy with those parameters changed:$query->with(['cursor' => $results->next]).criteria(): list<string>- the criteria it sets, amongQuery::CRITERIA(text,title,number,kind,jurisdictions,from,to,subjects,types,at);limit,cursorandsortare not criteria.
A source applies the criteria its Capabilities::$criteria name and refuses the others with a
NotSupportedException rather than answer unfiltered: in law, a search that silently drops its
jurisdiction or its date is a wrong answer.
Capabilities
| Property | Type | Default | |
|---|---|---|---|
kinds |
list of Kind |
[] |
what the source holds |
identifiers |
list of Scheme |
[] |
the identifiers it reads, besides its own |
criteria |
list of strings | [] |
the Query criteria its search applies |
jurisdictions |
list of strings | [] |
the countries or legal orders it covers: FR, EU |
versions |
bool | false |
whether it reads a text or an article as in force at a date |
pageSize |
?int | null |
the most results it gives per page |
pseudonymised |
bool | false |
whether its decisions come with the names of the persons removed |
holds(Kind),reads(Scheme),filters(string $criterion): booleans.Capabilities::OPERATIONS- operation names to interfaces:search,text,article,decision,citations,recent.Capabilities::operations(SourceInterface $source): list<string>- the operations it implements.
Token
An access token (Omnilex\Model\Token), what the PISTE sources sign with
(Authentication).
| Property | Type | Default |
|---|---|---|
accessToken |
string | required |
expiresAt |
?DateTimeImmutable | null (never expires) |
type |
string | Bearer |
scope |
?string | null |
isExpired(int $leeway = 0, ?DateTimeImmutable $now = null): bool - dead, or dead within
$leeway seconds; toArray() / Token::fromArray() (access_token, expires_at in ATOM,
type, scope) for a store.
Identifier, Identifiers and Scheme are on their own page: Identifiers.
Proposer une modification docs/models.md
Identifiers
Omnilex\Model\Identifier normalises an identifier so that two spellings of it compare equal,
and validates it on its shape, without the network: an identifier that parses is well
formed, not known to exist.
use Omnilex\Model\Identifier;
use Omnilex\Model\Scheme;
Identifier::ecli('ecli:fr:ccass:2018:c301117')->value; // ECLI:FR:CCASS:2018:C301117
Identifier::celex('CELEX:32016R0679')->value; // 32016R0679
Identifier::pourvoi('Pourvoi n° C 17-18.194')->value; // 17-18.194
Identifier::nor('jusc1732516d')->value; // JUSC1732516D
Identifier::legifrance('legiarti000006419320')->scheme; // Scheme::LEGIARTI
Identifier::parse('https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000038261631'); // jorftext:JORFTEXT000038261631
Identifier::parse('https://eur-lex.europa.eu/legal-content/FR/TXT/?uri=CELEX:32016R0679'); // celex:32016R0679
Identifier::parse('Code civil'); // null
Identifier::tryOf(Scheme::ECLI, 'ECLI:FR:CCASS'); // null
Identifier::celex('2016/679'); // InvalidArgumentException
| Scheme | Shape | Normalised as |
|---|---|---|
ECLI |
ECLI: country (2 letters) : court (1-7 characters) : year : ordinal (1-25 letters, digits, dots) |
upper case, with its ECLI: prefix |
CELEX |
sector, year, descriptor, number; a corrigendum R(02), a part (01); a consolidation date -20160504 |
upper case, without CELEX: |
ELI |
/eli/...: of the Union (http://data.europa.eu/eli/...) or of Légifrance (/eli/decret/.../jo/texte) |
its URI |
NOR |
4 letters, 7 digits, 1 letter | upper case |
POURVOI |
17-18.194, also 1718194, 17 18 194, with n° or its key letter |
17-18.194 |
LEGITEXT, LEGIARTI, LEGISCTA, JORFTEXT, JORFARTI, JORFCONT, JURITEXT, CETATEXT, CONSTEXT |
the prefix and 12 digits | upper case |
Every case of Omnilex\Model\Scheme (string-backed, the value is the lower-case name), with
label() and what kind() tells:
| Case | Value | label() |
kind() |
|
|---|---|---|---|---|
ECLI |
ecli |
ECLI |
DECISION |
European Case Law Identifier: ECLI:FR:CCASS:2018:C301117, ECLI:EU:C:2014:317 |
CELEX |
celex |
CELEX number |
- | a document of EU law: 32016R0679; consolidated: 02016R0679-20160504 |
ELI |
eli |
ELI |
- | European Legislation Identifier |
NOR |
nor |
NOR |
TEXT |
the French official number of a text of the Journal officiel |
POURVOI |
pourvoi |
numéro de pourvoi |
DECISION |
an appeal number at the Cour de cassation |
LEGITEXT |
legitext |
LEGITEXT identifier |
TEXT |
Légifrance: a consolidated text (a code, a law, a decree) |
LEGIARTI |
legiarti |
LEGIARTI identifier |
ARTICLE |
one version of an article of a consolidated text |
LEGISCTA |
legiscta |
LEGISCTA identifier |
- | a section of a consolidated text |
JORFTEXT |
jorftext |
JORFTEXT identifier |
TEXT |
a text as published in the Journal officiel |
JORFARTI |
jorfarti |
JORFARTI identifier |
ARTICLE |
an article as published in the Journal officiel |
JORFCONT |
jorfcont |
JORFCONT identifier |
- | one issue of the Journal officiel |
JURITEXT |
juritext |
JURITEXT identifier |
DECISION |
a decision of the judicial courts |
CETATEXT |
cetatext |
CETATEXT identifier |
DECISION |
a decision of the administrative courts |
CONSTEXT |
constext |
CONSTEXT identifier |
DECISION |
a decision of the Conseil constitutionnel |
isLegifrance() is true for the nine Légifrance prefixes.
parse() reads scheme:value, a page of Légifrance, EUR-Lex or the Publications Office, an ELI,
or a value whose shape tells its scheme. A bare seven-digit number is not read as a pourvoi (it
could be anything): give pourvoi:1718194 or the written form 17-18.194.
What an identifier tells
$ecli = Identifier::ecli('ECLI:FR:CCASS:2018:C301117');
$ecli->country(); // FR
$ecli->court(); // CCASS
$ecli->year(); // 2018
$celex = Identifier::celex('02016R0679-20160504');
$celex->sector(); // 0: a consolidated version (3: legislation, 6: case law, 1: treaties)
$celex->consolidatedOn(); // 2016-05-04
$celex->url(); // https://eur-lex.europa.eu/legal-content/FR/TXT/?uri=CELEX:02016R0679-20160504
$identifier->key(); // "celex:02016R0679-20160504"
$identifier->scheme->kind(); // Kind::DECISION for an ECLI, Kind::ARTICLE for a LEGIARTI, null when the scheme does not tell
url() gives a page only when the identifier alone tells which: EUR-Lex for a CELEX number and
an EU ECLI, the ELI itself, Légifrance for JORFTEXT, JURITEXT, CETATEXT, CONSTEXT. A LEGITEXT is
a code or a law, each with its own page: the models carry their url.
Identifiers
A model's identifiers is an Identifiers collection: each identifier once.
$decision->identifiers->value(Scheme::ECLI); // ECLI:FR:CCASS:2018:C301117
$decision->identifiers->values(Scheme::POURVOI); // ['17-18.194', '16-21.165']
$decision->identifiers->has(Scheme::ECLI);
$decision->identifiers->toArray(); // ['ecli' => [...], 'pourvoi' => [...]] for storage
Identifiers::fromArray($stored);
Identifier, method by method
| Method | |
|---|---|
Identifier::of(Scheme|string $scheme, string $value): Identifier |
normalised; InvalidArgumentException ("x" is not a valid CELEX number.) when the value is not of that scheme, PHP's ValueError for an unknown scheme name |
Identifier::tryOf(Scheme|string|null $scheme, ?string $value): ?Identifier |
the same, null instead of an exception (and for an unknown scheme or an empty value) |
Identifier::parse(string $input): ?Identifier |
from any string, null when nothing is recognised |
ecli(), celex(), eli(), nor(), pourvoi() |
of() with that scheme |
Identifier::legifrance(string $value) |
one of Légifrance's, its scheme read from its prefix |
$scheme, $value |
public, read-only |
key(): string |
scheme:value, what collections key on; also __toString() |
equals(Identifier $other): bool |
same scheme and value |
country(): ?string |
FR or EU: an ECLI's country, an ELI's by its host, EU for a CELEX number, FR otherwise |
court(): ?string |
an ECLI's court code |
year(): ?int |
an ECLI's or a CELEX number's year |
sector(): ?string |
a CELEX number's sector |
consolidatedOn(): ?DateTimeImmutable |
a consolidated CELEX number's date |
url(): ?string |
the page, when the identifier alone tells it |
Identifiers (iterable, countable, each identifier once in the order learnt; nulls given to it
are skipped):
| Method | |
|---|---|
new Identifiers(iterable), Identifiers::of(?Identifier ...) |
|
get(Scheme): ?Identifier, value(Scheme): ?string |
the first of that scheme |
all(?Scheme = null): list<Identifier>, values(Scheme): list<string> |
|
has(Scheme|Identifier): bool |
|
with(?Identifier ...): Identifiers, merge(Identifiers): Identifiers |
new collections |
keys(): list<string> |
scheme:value each |
toArray(): array<string, list<string>>, Identifiers::fromArray(array) |
for storage; fromArray() skips what no longer parses |
Proposer une modification docs/identifiers.md
Sources and search
Every source implements Omnilex\Source\SourceInterface - getName(), capabilities() - and
the capability interfaces of what its service does:
| Interface | Method | Answers |
|---|---|---|
SearchInterface |
search(Query) |
Results: a page of Reference; $results->next is the next cursor |
TextReaderInterface |
text($id, ?$at) |
a Text as in force on that day (today when null), or null |
ArticleReaderInterface |
article($id, ?$number, ?$at) |
an Article as in force on that day, or null |
DecisionReaderInterface |
decision($id) |
a Decision, or null |
CitationsInterface |
citations($id, $limit) |
a list of Citation, both ways |
RecentInterface |
recent($since, ?Query) |
Results: what is new since that day, newest first |
$source instanceof ArticleReaderInterface says whether a source reads articles;
Capabilities::operations($source) lists what it does; Registry::articles('legifrance') gives
it typed, or throws NotSupportedException.
What each one does
| legifrance | judilibre | eurlex | justice-administrative | |
|---|---|---|---|---|
| Holds | texts, articles, decisions | decisions | texts, articles, decisions | decisions |
| Legal order | France | France (judicial courts) | European Union | France (administrative courts) |
| Access | PISTE, OAuth | PISTE, OAuth or API key | open | open |
search() |
yes | yes | titles only | yes |
text() by |
LEGITEXT, JORFTEXT, NOR, ELI | - | CELEX, ELI | - |
article() by |
LEGIARTI; LEGITEXT + number; ELI | - | CELEX or ELI + number | - |
decision() by |
JURITEXT (judicial courts) | Judilibre id | ECLI, CELEX | its id, an ECLI of the Conseil d'État |
citations() of |
an article (LEGIARTI), a judicial decision | a decision: texts applied, decision contested, case law brought together | an act or a judgment | - |
recent() reads |
the texts' publication date; the decisions' date | the date a decision was created or updated in the base | the date of the document | the date a decision was added to the base |
| Version at a date | yes: every version of an article; a text as consolidated that day | - | yes: consolidated versions | - |
| Pseudonymised | not said by the API | yes | not said | yes |
| Per page, at most | 100 | 50 (10 000 results) | 100 | 200, no paging |
| Called live | no (no credentials) | no (no credentials) | yes (2026-10-04, 2026-10-26) | yes (2026-10-04, 2026-10-26) |
The search criteria
Query criteria a source cannot apply are refused (NotSupportedException), not dropped.
| Criterion | legifrance | judilibre | eurlex | justice-administrative |
|---|---|---|---|---|
text |
every word; "..." exact |
every word; "..." exact |
words of the title | every word; "..." exact |
title |
yes | - | words of the title | - |
number |
of a text, an article, a case | case number (a pourvoi normalised) | - | case number |
kind |
text (default), article, decision | decision | text, decision | decision |
jurisdictions |
decisions: judiciaire, administratif or constitutionnel (one) |
cc, ca, tj, tcom, cph, or a seat (ca_paris) |
a court of the Union: CJ, GCEU |
one court: CE, CAA69, TA75 |
from, to |
texts: signature; decisions: date | decision date | date of the document | date the decision was read |
subjects |
articles: the code's name | Judilibre themes | subject-matter codes (PROT) |
- |
types |
texts: LOI, DECRET...; judicial decisions: ARRET... |
arret, qpc, ordonnance... |
resource types: REG, DIR, JUDG |
Décision or Ordonnance (one) |
at |
texts and articles as in force that day | - | - | - |
sort |
relevance, newest, oldest | relevance, newest, oldest | newest, oldest | newest only |
The codes are each service's own: capabilities() tells which criteria apply, each package's
documentation where its codes come from (Judilibre: JudilibreSource::taxonomy()).
What the services do not allow
- Légifrance: the documentation reads a decision of the judicial courts only
(
/consult/juri); the administrative and constitutional case law is searched, not read. A whole code comes in one answer: read its articles one by one. The quotas are not public. - Judilibre: a decision is read by Judilibre's own identifier: an ECLI or a pourvoi is
searched first (
Query::$number). No texts. - EUR-Lex: the open endpoint searches the titles and the metadata, not the full texts. Acts whose text the Office does not publish divided into articles cannot be read article by article. Consolidated versions are documentary: only the Official Journal is authentic.
- Administrative courts: no published contract for the search interface (the site documents archives to download); no paging, no subject filter, no links to texts; ECLIs for the Conseil d'État only.
Choosing a source at run time
use Omnilex\Model\Identifier;
use Omnilex\Model\Kind;
use Omnilex\Model\Scheme;
$registry->having('decision'); // the usable sources that read decisions, by name
$registry->reading(Scheme::ECLI); // those whose Capabilities read an ECLI
$registry->reading(Kind::TEXT); // those that hold texts
$id = Identifier::parse($input); // what the user pasted: an ECLI, a CELEX, a Légifrance address
foreach ($registry->reading($id->scheme) as $name => $source) {
// ask $source for it
}
having(), reading() and usable() build the sources and leave out those whose options are
incomplete or refused (InvalidConfigException: EUR-Lex's language that is not an official
language of the Union, Judilibre's or the administrative courts' recent other than their two
values); get() and all() throw it.
Writing a source
A source of another service: Writing a provider.
Proposer une modification docs/sources.md
The law at a date
A text changes; a dispute is judged on the law as it stood at the time. text() and
article() take a day and answer with the version in force that day; without a day, with the
version in force today.
$article = $legifrance->article('LEGITEXT000006070987', 'L36-11', new DateTimeImmutable('2018-01-01'));
$article->version->from; // the day that wording started to apply
$article->version->to; // its last day (null: no end known)
$article->version->status; // Status::MODIFIED: replaced since
$article->versions; // every version of the article, oldest first
$article->isInForce(); // false: this is not today's wording
$today = $legifrance->article('LEGITEXT000006070987', 'L36-11');
$today->isInForce(); // true
null means the source knows no version for that day: the article did not exist yet, the text
was repealed, or the source does not hold that version's wording.
Version
from, to |
the days between which the version applies; to null when no end is known |
status |
IN_FORCE, FUTURE (adopted, not in force yet), MODIFIED (replaced by a later version), REPEALED, ANNULLED, EXPIRED, UNKNOWN |
id |
the identifier that reads that version again |
label, url |
Version::at($versions, $date) picks, among several, the one that applies on a day. When one
version ends the day the next starts - as Légifrance dates them - the later one wins.
How each source dates its versions
Légifrance keeps every version of every article: each has its own identifier (LEGIARTI),
its start, its end and its legal state. article() reads the list of versions, picks the one of
the day, and reads its wording. An article named by its LEGIARTI and no date is that very
version. A whole text (text(), LEGITEXT) is asked from the API as consolidated on the day; a
text of the Journal officiel (JORFTEXT, NOR, ELI) has one state: as published.
EUR-Lex publishes an act in the Official Journal, then consolidated versions each time it
is amended or corrected (CELEX 0 + number + - + date). text() and article() read the
consolidated version that was the state of the act on the day; before the first consolidation,
the act as published. A version's from is the day the text took that wording, not the day it
started to apply: between its adoption and its entry into force an act is given with
Status::FUTURE, and the dates of entry into force are in Text::$raw. Consolidated versions
are documentary tools: only the Official Journal is authentic. A consolidated CELEX number
(02016R0679-20160504) names its version: the date is not read.
Judilibre and the administrative courts hold decisions, which have no versions; a
decision may be corrected or withdrawn (Decision::$updatedOn, JudilibreSource::changes()).
Caching
A version that is no longer the current one (to set, status MODIFIED or REPEALED) never
changes: cache it for good, keyed on its id. Today's version changes the day the text is
amended: cache it for a day, or until recent() shows its text again. Never cache an
UnavailableException.
Proposer une modification docs/versions.md
Authentication: PISTE
The Légifrance and Judilibre APIs are behind PISTE, the French State's API portal. An application declared there signs in as itself with the OAuth 2.0 client credentials flow (RFC 6749 § 4.4):
POST https://oauth.piste.gouv.fr/api/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=...&client_secret=...&scope=openid
{"access_token": "...", "token_type": "Bearer", "expires_in": 3600, "scope": "openid"}
Each call then carries Authorization: Bearer <access_token>. The sandbox has its own
authorisation server (sandbox-oauth.piste.gouv.fr), its own API host
(sandbox-api.piste.gouv.fr) and its own credentials.
What to obtain
- An account on https://piste.gouv.fr (free).
- An application (one is created for the sandbox with the account; create one for production), and for each API - Légifrance, Judilibre - the acceptance of its terms of use and its subscription in the application.
- The application's client id and client secret (its OAuth credentials). Keep them in
.env.localor the secrets vault: never in the code.
Each API's own steps are in its package's README.
In the code
The factories of omnilex/legifrance and omnilex/judilibre take client_id, client_secret
and sandbox, and do the rest:
$legifrance = (new LegifranceSourceFactory($http, $tokenStore))->create([
'client_id' => $_ENV['PISTE_CLIENT_ID'],
'client_secret' => $_ENV['PISTE_CLIENT_SECRET'],
]);
Omnilex\Auth\ClientCredentials asks for a token on the first call, keeps it until a minute
before it dies, and asks for a fresh one after a 401. Its interfaces:
TokenProviderInterface |
token(): Token, forget(): what signs a source's calls |
ClientCredentials |
the client-credentials flow, for any authorisation server |
Piste::credentials($http, $id, $secret, sandbox: false, store: null) |
the same, on PISTE's servers |
StaticToken |
a token the application obtained itself |
TokenStoreInterface |
get, set, delete: where a token is kept between two requests of an application |
Without a store, a PHP application asks for a new token at every request it serves. With one,
the token is shared for the hour it lives. The Symfony bundle gives a store over cache.app
(Bridge\Symfony\CacheTokenStore); elsewhere, implement the three methods over your cache. The
store's key names the authorisation server and the client id, never the secret.
When it is refused
| The authorisation server refuses the client id or secret | AuthenticationException |
| The API refuses the token: API not subscribed, terms not accepted (401, 403) | AuthenticationException, after one retry with a fresh token on a 401 |
| The authorisation server or the API is down | UnavailableException |
| The application's quota is spent (429) | RateLimitedException |
All three extend UnavailableException: a caller that reads "refused" as "this article does not
exist" would be wrong, and cannot do so by accident.
Proposer une modification docs/authentication.md
Configuration reference
Every option below is read from the factories' code at this commit (populate() and what build()
reads). Options reach a factory through create($options), a Registry's options, or the
bundle's omnilex.sources.<name>.options (Symfony).
The registry
new Registry(iterable $factories, array $config);
// $config: ['eurlex' => ['factory' => 'eurlex', 'options' => ['language' => 'fr']], ...]
| Method | |
|---|---|
get(string $name): SourceInterface |
the source, built on the first call and kept |
search(), texts(), articles(), decisions(), citations(), recent() (each string $name) |
the source as that interface, or NotSupportedException (The "administratif" source does not read texts.) |
has(string $name): bool |
whether it is configured |
names(): list<string> |
the configured sources, in the configured order |
all(): array<string, SourceInterface> |
every configured source, built; throws for one whose options are incomplete |
usable(): array<string, SourceInterface> |
those that can be built: a source whose options are incomplete (InvalidConfigException) is left out |
having(string $operation): array |
the usable sources that do one thing: search, text, article, decision, citations, recent, or an interface's class name |
reading(Scheme|Kind $what): array |
the usable sources that read that kind of identifier, or hold that kind of document |
factories(): list<string> |
the factories installed: what factory may name |
options(string $name): array |
the options it is configured with (the factory's defaults not included) |
A later factory of the same name replaces an earlier one. A name not configured is an
InvalidConfigException (No "x" source; configured: eurlex, administratif.), so is a factory
not installed (No "x" factory for the "y" source; installed: eurlex.). Nothing is built before a
source is asked for.
How options are read
Omnilex\Config (an ArrayObject) holds the options given, with the factory's defaults under
them: defaults() sets each key not given or given as null. get($key, $default) gives the
default for a key missing, null or '' - so an empty environment variable is "not set". Before
build(), validateNotEmpty() refuses each key of omnilex.required_options that is missing, ''
or [] (The "legifrance" source needs: client_id, client_secret.).
The keys every factory sets for itself: omnilex.factory_name (what getName() reads) and
omnilex.required_options.
Options every source takes
Set by SourceFactory::createConfig() under each provider's own defaults:
| Option | Type | Default | |
|---|---|---|---|
user_agent |
string | omnilex/1.x (+https://github.com/glitchr-omnisense/omnilex) |
the User-Agent of every call |
throttle |
float | each provider's (below); 0.0 for a provider that sets none |
seconds left between two calls of the source |
base_uri |
string | each provider's | the service's address, to point at a copy or a proxy |
A factory's constructor takes the HTTP client and the token store:
new LegifranceSourceFactory(?HttpClientInterface $http = null, ?TokenStoreInterface $tokens = null);
Without a client, each factory makes its own (HttpClient::create() with timeout 60 seconds; 30
for Judilibre). The store is read by the PISTE sources only (Authentication).
The boolean options (sandbox, content, resolve_references) are read by Config::bool():
true, "1", "true", "yes" and "on" are true, anything else ("false", "0", "no", empty)
false, so an option given by an environment variable as a string reads as it says.
legifrance
| Option | Type | Default | Required | |
|---|---|---|---|---|
client_id |
string | - | yes | the PISTE application's OAuth client id |
client_secret |
string | - | yes | its secret |
sandbox |
bool | false |
no | PISTE's sandbox: https://sandbox-api.piste.gouv.fr/dila/legifrance/lf-engine-app and https://sandbox-oauth.piste.gouv.fr/api/oauth/token, with the sandbox application's credentials |
throttle |
float | 0.5 |
no | |
base_uri |
string | https://api.piste.gouv.fr/dila/legifrance/lf-engine-app (or the sandbox's) |
no | |
token_uri |
string | https://oauth.piste.gouv.fr/api/oauth/token (or the sandbox's) |
no | |
user_agent |
string | as above | no |
judilibre
| Option | Type | Default | Required | |
|---|---|---|---|---|
client_id, client_secret |
string | null |
these two, or key_id |
OAuth client credentials through PISTE |
key_id |
string | null |
the PISTE application's API key, sent as the KeyId header; when set, the OAuth pair is not used |
|
sandbox |
bool | false |
no | https://sandbox-api.piste.gouv.fr/cassation/judilibre/v1.0 and the sandbox's token server |
recent |
update or creation |
update |
no | the date recent() reads: created or updated in the base, or created; another value is an InvalidConfigException |
resolve_references |
bool | false |
no | resolve_references=true on every call: labels in place of keys in the answers (Court::$code is then null) |
throttle |
float | 0.5 |
no | |
base_uri |
string | https://api.piste.gouv.fr/cassation/judilibre/v1.0 (or the sandbox's) |
no | |
token_uri |
string | PISTE's (or the sandbox's) | no | |
user_agent |
string | as above | no |
Neither key_id nor both OAuth options: InvalidConfigException (The "judilibre" source needs: client_id and client_secret, or key_id.).
eurlex
| Option | Type | Default | Required | |
|---|---|---|---|---|
language |
ISO 639-1 | fr |
no | the titles', labels' and texts' language: one of the 24 official languages of the Union (bg, cs, da, de, el, en, es, et, fi, fr, ga, hr, hu, it, lt, lv, mt, nl, pl, pt, ro, sk, sl, sv); another is an InvalidConfigException |
content |
bool | true |
no | whether text() and decision() also fetch the document itself (one more call, up to a few megabytes); article() always does |
throttle |
float | 0.5 |
no | |
base_uri |
string | https://publications.europa.eu/webapi/rdf/sparql |
no | the SPARQL endpoint |
resource_uri |
string | https://publications.europa.eu/resource/ |
no | where the documents are read by CELEX number |
user_agent |
string | as above | no |
justice-administrative
| Option | Type | Default | Required | |
|---|---|---|---|---|
recent |
lastModified or Date_Lecture |
lastModified |
no | the date recent() reads: added to the base, or read in open court; another value is an InvalidConfigException |
throttle |
float | 1.0 |
no | |
base_uri |
string | https://opendata.justice-administrative.fr/recherche/api |
no | |
user_agent |
string | as above | no |
The bundle
omnilex.sources and omnilex.token_cache (Symfony).
Proposer une modification docs/configuration.md
Errors
Not found is null (or an empty list, an empty page): text(), article(), decision() answer
null for what the service does not know, citations() an empty list. Everything else is an
exception implementing Omnilex\Exception\OmnilexException (an interface extending \Throwable):
| Exception | Extends | Thrown when |
|---|---|---|
InvalidConfigException |
\InvalidArgumentException (a \LogicException) |
a source not configured, a factory not installed, a required option missing or empty (Légifrance's credentials, Judilibre's credentials or key), an option a source refuses (EUR-Lex's language, Judilibre's and the administrative courts' recent) |
NotSupportedException |
\LogicException |
the source does not do that: an operation it has no interface for (the registry's getters), an identifier it cannot read, a search criterion it cannot apply, an article asked without its number where one is needed |
ProviderException |
\RuntimeException |
the service answered with an error: any 4xx but those below, an answer that is not JSON or not what was expected, a route that no longer answers (the administrative courts' engine) |
UnavailableException |
ProviderException |
the service could not be reached, is down, or refuses for now: 5xx, 423, a transport error or a timeout; the authorisation server unreachable or answering 5xx |
AuthenticationException |
UnavailableException |
the credentials are refused: 401 (after one retry with a fresh token), 403, a 400 with a WWW-Authenticate challenge, a token refused by the authorisation server |
RateLimitedException |
UnavailableException |
429: slow down, or the PISTE application's quota is spent |
ProviderException and its children keep what the service said:
| Property | |
|---|---|
source |
the source's name (piste or the source's for the token server) |
status |
the HTTP status, when there was one |
retryAfter |
RateLimitedException only: the seconds of Retry-After, when numeric |
getMessage() |
[<source>] <message>: the service answered 503: <the body's first 300 characters, tags stripped>, unreachable: <transport error>, the service refuses the credentials (401): <challenge or body>, rate limited, retry in 30 s, the client id or secret is refused: <error_description> |
getPrevious() |
the HTTP client's exception, for a transport error |
How HTTP becomes an answer or an exception
HttpSource (every provider) maps each answer the same way:
| Answer | Result |
|---|---|
| 2xx | the body (JSON decoded for the JSON calls: a body that is not a JSON object is a ProviderException) |
| 404, 410 | null: not found |
| 401 | the token forgotten and asked afresh, the call made once more; refused again: AuthenticationException |
403, or 400 with WWW-Authenticate |
AuthenticationException |
| 429 | RateLimitedException, Retry-After kept |
| 423, 5xx | UnavailableException |
| any other 4xx | ProviderException |
| a transport error, a timeout | UnavailableException |
AuthenticationException and RateLimitedException extend UnavailableException on purpose: a
caller that reads "refused" as "this article does not exist" would be wrong, and cannot do so by
accident.
What to catch
use Omnilex\Exception\AuthenticationException;
use Omnilex\Exception\ProviderException;
use Omnilex\Exception\RateLimitedException;
use Omnilex\Exception\UnavailableException;
try {
$article = $registry->articles('legifrance')->article('LEGITEXT000006070721', '1240');
} catch (RateLimitedException $e) {
$retry = $e->retryAfter ?? 60; // try again later
} catch (AuthenticationException $e) {
$logger->critical($e->getMessage()); // the PISTE application: credentials, subscription, terms
} catch (UnavailableException) {
// down: show "unavailable", never "no such article", and cache nothing
} catch (ProviderException $e) {
$logger->error($e->getMessage(), ['status' => $e->status]);
}
if (null === $article) {
// not found: the service does not know it, or knows no version that day
}
InvalidConfigException and NotSupportedException are programming errors (LogicExceptions):
test Registry::has(), Capabilities::operations() and capabilities()->filters() rather than
catch them. A caller that caches answers never caches an UnavailableException; a legal watch
that meets one tries again later rather than record "nothing new".
Outside the family
InvalidArgumentException: fromIdentifier::of()and its shortcuts ("2016/679" is not a valid CELEX number.),Identifier::legifrance(),Query's dates ("x" is not a date.), EUR-Lex's search text without a word (The search text holds no word.) or a code of an authority table that is not one (types,subjects,jurisdictionswith other characters than letters, digits,_,.,-). EUR-Lex'slanguage, Judilibre's and the administrative courts'recentgiven wrong are anInvalidConfigExceptionwhen the source is built, as every misconfiguration.ValueError:Identifier::of()with a scheme name that is not aScheme.TypeError: in Symfony, a source injected under an interface it does not implement (Symfony).
No secret in a failure
A source keeps its credentials out of what it throws: a key in the query, the bearer of its token,
the client secret sent for the token (ClientCredentials) are masked in the HTTP client's message
(Http\Redaction::text()), the previous exception is a copy of the client's (Redaction::copy():
the same class and code), and no trace of the chain keeps its arguments, where the query, headers
and body stood (zend.exception_ignore_args off).
Proposer une modification docs/errors.md
Testing
Testing an application that reads the law through omnilex, and what the family's own tests cover.
The core ships no fake source and no conformance suite: its test doubles (Tests/StubSource.php,
Tests/DecisionsStub.php and their factories) are autoloaded only in its own development
(autoload-dev). An application tests its code against a real provider on a MockHttpClient, or
with a source of its own.
A provider on a MockHttpClient
Every factory and every source takes its HTTP client. Given Symfony's MockHttpClient, the
provider runs whole - its requests, its mapping, its errors - and nothing leaves the machine. For
the PISTE sources, Omnilex\Auth\StaticToken stands in for the OAuth exchange:
<?php
use Omnilex\Auth\StaticToken;
use Omnilex\Exception\UnavailableException;
use Omnilex\Legifrance\LegifranceSource;
use Omnilex\Source\ArticleReaderInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
use Symfony\Component\HttpClient\Response\MockResponse;
/** The application's code: an article quoted with its code and the day its wording applies from. */
final class Quote
{
public function __construct(private readonly ArticleReaderInterface $legifrance)
{
}
public function of(string $id): ?string
{
try {
$article = $this->legifrance->article($id);
} catch (UnavailableException) {
return null; // down, refused or slowed: not "no such article"
}
return null === $article ? null : \sprintf('%s, art. %s (since %s): %s', $article->textTitle, $article->number, $article->version?->from?->format('Y-m-d'), $article->content);
}
}
final class QuoteTest extends TestCase
{
public function testAnArticleIsQuotedWithItsCodeAndItsVersion(): void
{
$answer = new JsonMockResponse(['article' => [
'id' => 'LEGIARTI000032041571', 'num' => '1240', 'etat' => 'VIGUEUR', 'dateDebut' => '2016-10-01',
'texte' => 'Tout fait quelconque de l\'homme, qui cause à autrui un dommage, oblige celui par la faute duquel il est arrivé à le réparer.',
'textTitles' => [['titre' => 'Code civil', 'nature' => 'CODE', 'cid' => 'LEGITEXT000006070721']],
]]);
$source = new LegifranceSource(new MockHttpClient($answer), new StaticToken('test-token'), throttle: 0.0);
self::assertSame('Code civil, art. 1240 (since 2016-10-01): Tout fait quelconque de l\'homme, qui cause à autrui un dommage, oblige celui par la faute duquel il est arrivé à le réparer.', (new Quote($source))->of('LEGIARTI000032041571'));
self::assertSame('https://api.piste.gouv.fr/dila/legifrance/lf-engine-app/consult/getArticle', $answer->getRequestUrl());
self::assertContains('Authorization: Bearer test-token', $answer->getRequestOptions()['headers']);
}
public function testNothingWhenLegifranceIsDown(): void
{
$source = new LegifranceSource(new MockHttpClient(new MockResponse('', ['http_code' => 503])), new StaticToken('t'), throttle: 0.0);
self::assertNull((new Quote($source))->of('LEGIARTI000032041571'));
}
}
$ vendor/bin/phpunit --testdox tests/
Quote
✔ An article is quoted with its code and its version
✔ Nothing when legifrance is down
OK (2 tests, 4 assertions)
(run on 2026-10-26 in the harness's image, PHPUnit 11.5.57, no network)
- Build the source directly (
new LegifranceSource($http, $token, throttle: 0.0)), or through its factory withthrottle: 0in the options: the default throttle sleeps between two calls. - Each provider's
Tests/Fixturesholds answers to start from: recorded from the real service for EUR-Lex and the administrative courts (2026-10-04), composed from the published descriptions for Légifrance and Judilibre. The harness keeps two EUR-Lex answers indocker/harness/recorded/. - A 503 tests "down" (
UnavailableException), a 429 withRetry-Afterthe slowed path, a 401 twice the refused credentials, a 404 "not found" (null). ClientCredentialswith aMockHttpClientanswering the token server tests the OAuth exchange itself, aTokenStoreInterfacein memory what is kept (Authentication).
A source of the application's own
For code that only needs models back, a class implementing the capability interfaces it needs
(and SourceInterface's getName() and capabilities()), given to the registry through a
factory - the core's own Tests/StubSourceFactory.php and Tests/DecisionsStubFactory.php are
such doubles, to copy rather than depend on.
Symfony
In a functional test, the registry is the public service Omnilex\Registry. Replace the HTTP
client the factories receive by configuring framework.http_client.mock_response_factory in the
test environment, or configure the source under test with a factory of the application's own
(autoconfigured). Tests/Bridge/OmnilexBundleTest.php boots a kernel the latter way. Not run in
an application for this page.
The family's own tests
The workspace (~/Sites/omnilex, every package from its checkout) runs 143 tests, 747 assertions
(Verified, and not):
| Test case | What it checks |
|---|---|
RegistryTest (5) |
sources built once by name; typed getters and their refusal; a source without its options left out of usable()/having()/reading(); a required option; an unknown factory |
HttpSourceTest (10) |
a call's query (a list repeats its key, null left out) and headers; 404 as null; an identifier read from a string; a criterion refused; 5xx, 423 and transport errors as UnavailableException; 429 with Retry-After; another 4xx as ProviderException; a token asked once and again after a 401; refused credentials; the throttle |
ClientCredentialsTest (6) |
the OAuth request; a token about to die replaced; the store sparing the server; refused credentials; the server down; a static token |
IdentifierTest (52) |
48 spellings parsed; normalisation; invalid identifiers refused; what an identifier tells; a collection keeping each once |
ModelTest (5) |
the version in force on a day; a query's criteria; a text's article whatever the spelling; capabilities and operations; a token's death |
OmnilexBundleTest (2) |
a kernel with the sources injected by name as what they do; the PISTE token in the application's cache |
BareTest (3) |
bare --recorded --json in a process of its own: no framework class loaded, a text at a date from recorded answers; loading the bundle does name the framework |
EurlexSourceTest (17), JudilibreSourceTest (14), JusticeAdministrativeSourceTest (11), LegifranceSourceTest (18) |
each provider on MockHttpClient and its fixtures (their manuals) |
Proposer une modification docs/testing.md
The Docker harness
docker/ runs this package with every omnilex/* source installed - from GitHub (branch 1.x),
or from the checkouts beside this one when OMNILEX_PLUGINS=../.. is set in docker/.env - and
a console that asks the real services.
cd docker && cp .env.dist .env # PISTE_CLIENT_ID and PISTE_CLIENT_SECRET, when you have them
docker compose run --rm omnilex sources
| Command | |
|---|---|
sources |
the sources installed and configured, what each does, holds, filters by and reads |
search <source> [text] |
--title, --number, --from, --to, --at, --sort |
recent <source> <since> |
what is new since a day |
text <source> <id> |
a text as in force --at a day (JSON; --full for the whole content) |
article <source> <id> [number] |
one article --at a day (JSON) |
decision <source> <id> |
one decision (JSON; --full) |
citations <source> <id> |
the links, both ways; --limit each way |
bare |
plain PHP: the registry built by hand, a regulation as in force at a date asked of EUR-Lex, what PHP loaded |
test |
every package's tests |
search and recent take --kind (text, article, decision), --jurisdiction,
--subject, --type (repeatable, the source's own codes), --limit, --cursor, and --format
(table, json).
The configured sources are eurlex and administratif (no key), legifrance and judilibre
(when PISTE_CLIENT_ID and PISTE_CLIENT_SECRET are set; PISTE_SANDBOX=1 for the sandbox
application's credentials).
docker compose run --rm omnilex text eurlex 32016R0679 --at 2018-06-01
docker compose run --rm omnilex article eurlex 32016R0679 17
docker compose run --rm omnilex decision eurlex ECLI:EU:C:2014:317
docker compose run --rm omnilex citations eurlex 32016R0679 --limit 10
docker compose run --rm omnilex search eurlex "protection des données" --kind text --type REG --from 2016-01-01
docker compose run --rm omnilex search administratif urbanisme --jurisdiction CE --from 2026-09-01
docker compose run --rm omnilex recent administratif 2026-10-01 --jurisdiction CE
docker compose run --rm omnilex article legifrance LEGITEXT000006070987 L36-11 --at 2018-01-01
docker compose run --rm omnilex search judilibre expropriation --jurisdiction cc --from 2020-01-01
Bare: no bundle, no container
The console above is a symfony/console application over a registry built by hand; bare is
less still - one PHP script, docker/harness/bin/bare, that requires the autoloader and nothing
else. It builds the Registry from the source packages installed, asks each source that can be
built what it does, asks EUR-Lex for the regulation 32016R0679 as in force on 1 June 2018 - the
real service, its metadata only, or with --recorded the answers kept in
docker/harness/recorded/ - then lists what PHP loaded and exits 1 if a class of a framework is
among it (Symfony\Component\DependencyInjection, Config, HttpKernel, HttpFoundation, a
bundle, Doctrine, Twig):
$ docker compose run --rm omnilex bare
Omnilex in bare PHP: the registry built by hand, no bundle, no container.
legifrance its credentials are not set
judilibre its credentials are not set
eurlex search text article decision citations recent
administratif search decision recent
EUR-Lex, 32016R0679 as in force on 2018-06-01, asked of publications.europa.eu:
Règlement (UE) 2016/679 du Parlement européen et du Conseil du 27 avril 2016 relatif à la protection des personnes physiques à l'égard du traitement des données à caractère personnel et à la libre circulation de ces données, et abrogeant la directive 95/46/CE (règlement général sur la protection des données) (Texte présentant de l'intérêt pour l'EEE)
REG of 2016-04-27, in force
version 02016R0679-20160504, from 2016-05-04, in_force (of 32016R0679, 02016R0679-20160504)
Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, Doctrine, Twig): none
bare --recorded --json prints the same whole, every class and file loaded, without a call:
Tests/BareTest.php runs it in a process of its own and checks the list.
The image is php:8.4-cli-alpine with Composer; the harness's packages live in the harness
volume of the omnilex-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.
Proposer une modification docs/harness.md
Writing a provider
A provider is a package of its own, omnilex/<service>, requiring omnisense/omnilex and
symfony/http-client, with:
- a factory extending
Omnilex\Source\SourceFactory:populate(Config)sets its name (omnilex.factory_name), its required options (omnilex.required_options) and the defaults of the others;build(Config)returns the source, on$this->http(the application's client, or one of its own whennull) and, for a service that signs in,$this->tokens(the application's token store).SourceFactory::userAgent($c)gives theUser-Agentto send; - a source extending
Omnilex\Source\HttpSourceand implementing the capability interfaces of what its service does, and only those; capabilities()true to the code: the kinds held, the identifiers read besides its own, theQuerycriteria its search applies -accept()refuses every other one;- tests on
MockHttpClient, with the service's answers inTests/Fixtures.
What HttpSource gives
| Member | |
|---|---|
__construct(HttpClientInterface $http, string $baseUri, array $headers = [], float $throttle = 0.0, ?TokenProviderInterface $auth = null) |
headers sent with every call; calls signed with Authorization: <type> <token> when a token provider is given |
getJson(string $path, array $query = [], array $headers = []): ?array |
a GET of JSON; null on 404 or 410 |
postJson(string $path, array $body, array $headers = []): ?array |
a POST of a JSON body, JSON back |
get(string $path, array $query = [], array $headers = []): ?string |
a GET of text (XHTML, XML) |
accept(Query $query, array $ignored = []) |
refuses each criterion the query sets that capabilities()->criteria does not name (except $ignored: recent() passes ['from', 'to']) |
identify(Identifier|string $id, Scheme ...$schemes): ?Identifier |
the identifier when it is of one of those schemes, else null (the source's own identifier, kept as the string) |
plain(?string $html): ?string |
HTML as plain text: blocks become lines, entities decoded, white space collapsed |
day(int|string|null $value): ?DateTimeImmutable |
a day from what services send (2018-12-20, an ISO date-time, milliseconds since 1970 read in Paris time); null for nothing, for what does not parse, and for 2900 and later ("no end") |
A path starting with http is used as it is, else appended to $baseUri. A query value that is
a list repeats its key (type=arret&type=qpc), null and '' are left out, booleans are
true/false. The throttle sleeps what is left of the interval since the last call. The errors
are mapped once, for every source: Errors.
A whole provider
A source of a case-law database that searches by text and dates and reads a decision by its id or its ECLI:
<?php
namespace Acme\Hudoc;
use Omnilex\Config;
use Omnilex\Source\SourceFactory;
use Omnilex\Source\SourceInterface;
use Symfony\Component\HttpClient\HttpClient;
final class HudocSourceFactory extends SourceFactory
{
protected function populate(Config $c): void
{
$c->defaults([
'omnilex.factory_name' => 'hudoc',
'omnilex.required_options' => [],
'base_uri' => 'https://hudoc.example/api',
'throttle' => 0.5,
]);
}
protected function build(Config $c): SourceInterface
{
return new HudocSource($this->http ?? HttpClient::create(['timeout' => 30]), (string) $c->get('base_uri'), ['User-Agent' => self::userAgent($c)], (float) $c['throttle']);
}
}
<?php
namespace Acme\Hudoc;
use Omnilex\Model\Capabilities;
use Omnilex\Model\Court;
use Omnilex\Model\Decision;
use Omnilex\Model\Identifier;
use Omnilex\Model\Identifiers;
use Omnilex\Model\Kind;
use Omnilex\Model\Query;
use Omnilex\Model\Reference;
use Omnilex\Model\Results;
use Omnilex\Model\Scheme;
use Omnilex\Source\DecisionReaderInterface;
use Omnilex\Source\HttpSource;
use Omnilex\Source\SearchInterface;
final class HudocSource extends HttpSource implements SearchInterface, DecisionReaderInterface
{
public function getName(): string
{
return 'hudoc';
}
public function capabilities(): Capabilities
{
return new Capabilities(kinds: [Kind::DECISION], identifiers: [Scheme::ECLI], criteria: ['text', 'from', 'to'], jurisdictions: ['COE'], pageSize: 50);
}
public function search(Query $query): Results
{
$this->accept($query); // a criterion not in capabilities(): NotSupportedException
$page = max(0, (int) $query->cursor);
$data = $this->getJson('search', ['q' => $query->text, 'from' => $query->from?->format('Y-m-d'), 'to' => $query->to?->format('Y-m-d'), 'size' => min($query->limit, 50), 'page' => $page]) ?? [];
return new Results(array_map(fn (array $hit) => new Reference(
kind: Kind::DECISION,
id: (string) $hit['itemid'],
title: (string) $hit['docname'],
identifiers: Identifiers::of(Identifier::tryOf(Scheme::ECLI, $hit['ecli'] ?? null)),
date: self::day($hit['judgementdate'] ?? null),
source: $this->getName(),
raw: $hit,
), $data['hits'] ?? []), ($data['more'] ?? false) ? (string) ($page + 1) : null, $data['total'] ?? null);
}
public function decision(Identifier|string $id): ?Decision
{
$ecli = self::identify($id, Scheme::ECLI); // an ECLI, or the source's own id as given
$data = $this->getJson('documents/'.rawurlencode($ecli?->value ?? (string) $id)); // 404: null
if (null === $data) {
return null;
}
return new Decision(
id: (string) $data['itemid'],
title: $data['docname'] ?? null,
identifiers: Identifiers::of(Identifier::tryOf(Scheme::ECLI, $data['ecli'] ?? null)),
court: new Court('European Court of Human Rights', 'ECHR', 'COE'),
date: self::day($data['judgementdate'] ?? null),
content: self::plain($data['html'] ?? null),
html: $data['html'] ?? null,
language: 'en',
source: $this->getName(),
raw: $data,
);
}
}
Used on a MockHttpClient that answers a search, the decision 001-1, a 404 and a 502:
$registry = new Registry([new HudocSourceFactory($http)], ['hudoc' => ['factory' => 'hudoc', 'options' => ['throttle' => 0]]]);
foreach ($registry->search('hudoc')->search(new Query(text: 'article 8', from: '2024-01-01')) as $reference) {
echo $reference->date->format('Y-m-d'), ' ', $reference->id, ' ', $reference->title, ' ', $reference->identifiers->value(Scheme::ECLI), "\n";
}
$decision = $registry->decisions('hudoc')->decision('001-1');
echo $decision->court->name, ': ', str_replace("\n", ' / ', $decision->content), "\n";
var_dump($registry->decisions('hudoc')->decision('001-404'));
// then: a search with jurisdictions, and decision('down')
2024-01-15 001-1 CASE OF A v. B ECLI:CE:ECHR:2024:0115JUD000000120
European Court of Human Rights: PROCEDURE / 1. The case originated...
NULL
The "hudoc" source cannot filter by jurisdictions.
Omnilex\Exception\UnavailableException: [hudoc] the service answered 502: Bad gateway
(run on 2026-10-26 in the harness's image, no network)
Rules the existing providers keep
- Unknown is
nullor an empty page, never an exception; down, refused or slowed is nevernull. - A criterion the service cannot apply is refused (
accept(), orNotSupportedException::criterion()for a combination: one court at a time, a number along with a text), not dropped. - A string identifier is read with
identify(); an identifier of a scheme the source does not read isNotSupportedException::identifier(), notnull. - Decisions published pseudonymised are given as they are,
pseudonymised: true, and nothing tries to put the names back. - Dates through
day(); texts throughplain(); what the models do not carry inraw, the full texts removed from it.
Registering it
- Plain PHP: give the registry an instance.
- Symfony: a service implementing
SourceFactoryInterfaceis autoconfigured with the tagomnilex.source_factory; the bundle gives the HTTP client and the token store to its own four factories only, so an application's factory takes them by autowiring. - The harness: an entry in
docker/harness/plugins.phpand, for the console, indocker/harness/config/sources.php.
Proposer une modification docs/providers.md
Verified, and not
What the tests prove
Run on 2026-10-26 with the harness's image against the workspace (the core and its four providers from their checkouts), PHP 8.4, PHPUnit 11.5.57, no network: 143 tests, 747 assertions, green (Testing).
- The core: the registry, the HTTP mapping of every status, the throttle, the OAuth client credentials and the token store, 48 spellings of identifiers, the models' rules (the version in force on a day), the Symfony bundle in a booted kernel, and plain PHP with no class of a framework loaded.
- Each provider on
MockHttpClient: EUR-Lex and the administrative courts on answers recorded from the real services on 2026-10-04; Légifrance and Judilibre on answers composed from their published descriptions (the Swagger, the OpenAPI file and its examples).
What was called live
- EUR-Lex, on 2026-10-26, from the harness's image: article 17 of regulation 2016/679 as in force on 1 June 2018 (Installation), a search of the titles, the judgment ECLI:EU:C:2014:317 and its links, the versions of regulation 2016/679 (the provider's manual). Earlier, as its manual states: 2026-10-04 (the GDPR, directive 2006/112/EC with its 28 versions, the repealed directive 95/46/EC).
- The administrative courts' open data, on 2026-10-26: the Conseil d'État's decisions added since 2026-10-20 and one of them read whole (the provider's manual); every search route on 2026-10-04.
What is not verified
- Légifrance and Judilibre were never called: no PISTE credentials. The token exchange, the request bodies and the reading of the answers follow the published descriptions; each provider's manual lists what is unchecked.
- The Laravel service provider of Laravel: written from the contract.
- The known defects found while writing this manual, each fixed since with its test: EUR-Lex
dropping
textwhentitleis set, configuration errors raised asInvalidArgumentExceptionand breakingusable()(Sources and search), boolean options read with(bool)(Configuration).
Responsibility
omnisense/omnilex and its omnilex/* providers are independent, unofficial clients: none is
affiliated with, endorsed by or sponsored by the DILA (Légifrance), the Cour de cassation
(Judilibre), the PISTE platform, the Publications Office of the European Union or the Conseil
d'État. Each service keeps its own terms and re-use conditions, named with a link in each
provider's README. Consolidated texts are documentary; only the official journals are authentic.
Whoever installs and runs these packages is responsible for complying with those terms and for the
use made of what they return - in particular, never re-identifying the persons named in a
pseudonymised decision.