Aller au contenu

omnisense/omnilex

Omnilex: one contract for the sources of law - texts, codes and case law, searched with filters (jurisdiction, date, subject, type), read in the version in force at a date, their links followed, what is new listed - from Légifrance, Judilibre, EUR-Lex and the French administrative courts' open data; identifiers (ECLI, NOR, CELEX, pourvoi, Légifrance) normalised and validated; and its Symfony bundle.

Étage
Famille
Version
1.x
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnisense/omnilex:1.x-dev

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

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/omnilex requires symfony/http-client-contracts (^3.0) and nothing else: no framework, no HTTP client of its own. It replaces omniscient/omnilex: a composer.json that still requires the old name resolves to this package.
  • Each provider requires omnisense/omnilex and symfony/http-client (6.4, 7 or 8). Its factory takes an HttpClientInterface (the application's, a MockHttpClient in 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, and psr/cache with 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, a MockHttpClient in 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\OmnilexBundle registers the factories on the application's http_client, keeps the PISTE tokens in cache.app, builds the registry from config/packages/omnilex.yaml and 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 Registry once, where the framework builds its services (a service provider, a container definition), as the script above does.
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 is IN_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-11 and l 36 11 all become L3611.

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 unknown from never 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 later from wins.

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, among Query::CRITERIA (text, title, number, kind, jurisdictions, from, to, subjects, types, at); limit, cursor and sort are 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

  1. An account on https://piste.gouv.fr (free).
  2. 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.
  3. The application's client id and client secret (its OAuth credentials). Keep them in .env.local or 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: from Identifier::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, jurisdictions with other characters than letters, digits, _, ., -). EUR-Lex's language, Judilibre's and the administrative courts' recent given wrong are an InvalidConfigException when the source is built, as every misconfiguration.
  • ValueError: Identifier::of() with a scheme name that is not a Scheme.
  • 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 with throttle: 0 in the options: the default throttle sleeps between two calls.
  • Each provider's Tests/Fixtures holds 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 in docker/harness/recorded/.
  • A 503 tests "down" (UnavailableException), a 429 with Retry-After the slowed path, a 401 twice the refused credentials, a 404 "not found" (null).
  • ClientCredentials with a MockHttpClient answering the token server tests the OAuth exchange itself, a TokenStoreInterface in 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 when null) and, for a service that signs in, $this->tokens (the application's token store). SourceFactory::userAgent($c) gives the User-Agent to send;
  • a source extending Omnilex\Source\HttpSource and 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, the Query criteria its search applies - accept() refuses every other one;
  • tests on MockHttpClient, with the service's answers in Tests/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 null or an empty page, never an exception; down, refused or slowed is never null.
  • A criterion the service cannot apply is refused (accept(), or NotSupportedException::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 is NotSupportedException::identifier(), not null.
  • Decisions published pseudonymised are given as they are, pseudonymised: true, and nothing tries to put the names back.
  • Dates through day(); texts through plain(); what the models do not carry in raw, the full texts removed from it.

Registering it

  • Plain PHP: give the registry an instance.
  • Symfony: a service implementing SourceFactoryInterface is autoconfigured with the tag omnilex.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.php and, for the console, in docker/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 text when title is set, configuration errors raised as InvalidArgumentException and breaking usable() (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.

Proposer une modification docs/verified.md