Aller au contenu

omnisense/omnischolar

Omnischolar: one contract for a researcher's publications - author profiles, works, books and their editions, citations - read from OpenAlex, HAL, Crossref, ORCID, Open Library, arXiv and INSPIRE, merged and deduplicated, exported as BibTeX, RIS or CSL-JSON; 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/omnischolar:1.x-dev

Omnischolar is one PHP contract for a researcher's publications and profile, whoever knows them: OpenAlex, HAL, Crossref, ORCID, Open Library, arXiv and INSPIRE-HEP. The application asks a source by name for an author, their works, one work or a search, gets neutral models, merges what several sources say into one list and exports it for a reference manager:

$author = $registry->get('openalex')->author('A5108007452');
$author->metrics->hIndex;                                    // OpenAlex's count

$works = (new Collector($registry))->collect([               // every page of each source, merged
    'openalex' => 'A5108007452',
    'hal' => 'Keitaro Nakatani',
    'crossref' => '0009-0005-1387-7295',
]);
$works[0]->sources;                                          // ['openalex', 'hal']: one record per work
(new Export())->bibtex($works);                              // @article{malletroit2024complete, ...}

Each service is a provider package (omnischolar/openalex, omnischolar/hal, omnischolar/crossref, omnischolar/orcid, omnischolar/openlibrary, omnischolar/arxiv, omnischolar/inspire) with a factory that builds a source. Every source answers the same four questions (author(), works(), work(), search()) and says with its capabilities() which it can answer and what its answers hold; an operation it does not do is a NotSupportedException. Identifiers (DOI, ISBN, ISSN, arXiv, HAL, idHAL, ORCID, OpenAlex, INSPIRE, Open Library, PubMed) are normalised and checked without the network. The Merger makes one record of the works several sources list, each field from the source trusted for it, and groups the editions of a book; the Export writes BibTeX, RIS and CSL-JSON. 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 researcher's or a lab's site that lists its publications, kept in step with the open databases rather than typed by hand, each work once.
  • A profile page: positions and degrees (ORCID, INSPIRE), counts (OpenAlex, INSPIRE), books with their covers (Open Library).
  • A bibliography to download (.bib, .ris, CSL-JSON for citeproc or pandoc).

It is not a citation database of its own: counts are each source's, and differ from one to the next. Every service is open and needs no key (OpenAlex takes an optional one).

Providers

Seven packages. "Capabilities" is what each source's capabilities() answers, read from the code at this commit and printed by the harness's bare script.

Package Factory name Capabilities Reads authors by Called live
omnischolar/openalex openalex author, works, work, search, metrics, citations, abstracts, open_access, affiliations OpenAlex A..., ORCID 2026-10-26
omnischolar/hal hal author, works, work, search, abstracts, open_access, domains idHAL, ORCID; works by name too 2026-10-26
omnischolar/crossref crossref works, work, search, citations, abstracts works by ORCID or name 2026-10-26
omnischolar/orcid orcid author, works, work, affiliations, abstracts ORCID 2026-10-26
omnischolar/openlibrary openlibrary author, works, work, search, covers Open Library OL...A, name 2026-10-26
omnischolar/arxiv arxiv works, work, search, abstracts, open_access, domains works by name 2026-10-26
omnischolar/inspire inspire author, works, work, search, metrics, citations, abstracts, affiliations, domains INSPIRE record, BAI, ORCID; works by name too 2026-10-26

What each reads, filters and allows: Sources.

How this manual is organised

Proposer une modification docs/index.md

Installation

composer require omnisense/omnischolar omnischolar/openalex omnischolar/hal omnischolar/openlibrary

Requirements

  • PHP 8.2 or later. With ext-intl, the Merger compares titles across accents more thoroughly (a fallback table covers the Latin accents without it).
  • omnisense/omnischolar requires symfony/http-client-contracts (^3.0) and nothing else: no framework, no HTTP client of its own. It replaces omniscient/omnischolar: a composer.json that still requires the old name resolves to this package.
  • Each provider requires omnisense/omnischolar and symfony/http-client (6.4, 7 or 8); arXiv needs ext-dom (its answers are Atom). A factory takes an HttpClientInterface (the application's, a MockHttpClient in a test) and makes its own with HttpClient::create() when given none.
  • The Symfony bundle needs symfony/config, symfony/dependency-injection and symfony/http-kernel; 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 Omnischolar\Collector;
use Omnischolar\Export;
use Omnischolar\Hal\HalSourceFactory;
use Omnischolar\OpenAlex\OpenAlexSourceFactory;
use Omnischolar\Registry;
use Omnischolar\Source\Query;
use Symfony\Component\HttpClient\HttpClient;

$http = HttpClient::create();
$registry = new Registry([new OpenAlexSourceFactory($http), new HalSourceFactory($http)], [
    'openalex' => ['factory' => 'openalex', 'options' => ['mailto' => getenv('OMNISCHOLAR_MAILTO') ?: null]],   // your address: OpenAlex asks for one
    'hal' => ['factory' => 'hal'],
]);

$author = $registry->get('openalex')->author('A5108007452');
echo $author->name, ': ', $author->metrics->works, ' works, h-index ', $author->metrics->hIndex, "\n";

$works = (new Collector($registry))->collect(['openalex' => 'A5108007452', 'hal' => 'Keitaro Nakatani'], new Query(from: 2024, to: 2024));
foreach ($works as $work) {
    echo $work->date, '  ', $work->title, ' - ', $work->venue?->name, ' [', implode(', ', $work->sources), "]\n";
}
echo (new Export())->bibtex($works[0]);
$ php bare.php
Keitaro Nakatani: 387 works, h-index 53
2024-10-04  Complete kinetic and photochemical characterization of the multi-step photochromic reaction of donor–acceptor Stenhouse adducts - Physical Chemistry Chemical Physics [openalex, hal]
2024-09-25  Cascade Fluorescence Modulation in Photochromic Microcapsules - ACS Applied Materials & Interfaces [openalex, hal]
2024-09-03  Tuning the Thermal Stability of Tetra ‐ o– chloroazobenzene Derivatives by Transforming Push‐Pull to Push‐Push Systems - Chemistry - A European Journal [openalex, hal]
2024-01-01  Acid-sensitive photoswitches: towards catalytic on-demand release of stored light energy - Chemical Science [openalex, hal]
@article{malletroit2024complete,
  author = {Mallétroit, Julien and Djian, Aurélie and Nakatani, Keitaro and Xie, Juan and Métivier, Rémi and Laurent, Guillaume},
  title = {{Complete kinetic and photochemical characterization of the multi-step photochromic reaction of donor–acceptor Stenhouse adducts}},
  journal = {Physical Chemistry Chemical Physics},
  year = {2024},
  month = {oct},
  volume = {27},
  number = {3},
  pages = {1320--1326},
  doi = {10.1039/d4cp02267j},
  issn = {1463-9076},
  hal_id = {hal-04737711},
  url = {https://doi.org/10.1039/d4cp02267j},
  language = {en},
  keywords = {DASA, photochromism, photoisomerization, reaction kinetics, quantum yield, double irradiation, temperature dependence}
}

(called live on 2026-10-26 from the harness's image, without a mailto: each work once, from OpenAlex and HAL merged; on 2026-10-05 the same script counted 288 works and an h-index of 51)

No key, no account: the script asks the real services. That is all there is to it:

  • a factory per source package (OpenAlexSourceFactory, HalSourceFactory, CrossrefSourceFactory, OrcidSourceFactory, OpenLibrarySourceFactory, ArxivSourceFactory, InspireSourceFactory), 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());
  • the registry, built by hand from the factories and the sources' options, by name;
  • the sources it gives, and what works on them: the Collector, the Merger, the Export.

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 omnischolar bare (harness).

One source

use Omnischolar\OpenAlex\OpenAlexSourceFactory;
use Omnischolar\Source\Query;
use Symfony\Component\HttpClient\HttpClient;

$openalex = (new OpenAlexSourceFactory(HttpClient::create()))->create(['mailto' => 'support@glitchr.io']);

$author = $openalex->author('A5108007452');
$author->metrics->hIndex;                    // 53 on 2026-10-26
$author->orcid();                            // 0009-0005-1387-7295

$page = $openalex->works('A5108007452', new Query(from: 2024, to: 2024));
foreach ($page as $work) {
    echo $work->year, ' ', $work->title, ' - ', $work->venue?->name, "\n";
}
$next = $openalex->works('A5108007452', new Query(cursor: $page->next));   // the next page

An author or a work is named by an Identifier or a string: one of the source's own identifiers ('A5108007452', 'OL7092953A', 'keitaro-nakatani', 'Juan.M.Maldacena.1'), a resolver URL (https://orcid.org/..., https://doi.org/...), or - for the sources that search by name (HAL, Crossref, Open Library, arXiv, INSPIRE) - a name.

Several sources

use Omnischolar\Collector;
use Omnischolar\Registry;

$registry = new Registry(
    [new OpenAlexSourceFactory($http), new HalSourceFactory($http), new CrossrefSourceFactory($http)],
    [
        'openalex' => ['factory' => 'openalex', 'options' => ['mailto' => 'support@glitchr.io']],
        'hal' => ['factory' => 'hal'],
        'crossref' => ['factory' => 'crossref', 'options' => ['mailto' => 'support@glitchr.io']],
    ],
);
$collector = new Collector($registry);
$works = $collector->collect(['openalex' => 'A5108007452', 'hal' => 'Keitaro Nakatani']);
$collector->incomplete;   // the sources skipped (down, rate limited): do not cache this answer

Collector::collect() follows every page of each source (up to $max), then merges. To read one source's pages into one list, Collector::all($source, $author, $query, $max).

In a framework

  • Symfony: Omnischolar\Bridge\Symfony\OmnischolarBundle registers the factories on the application's http_client, builds the registry from config/packages/omnischolar.yaml, and autowires each source by its name, the Collector, the Merger and the Export: see Symfony. Its components (symfony/config, symfony/dependency-injection, symfony/http-kernel) 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.

Errors

Not found is null or an empty page; everything else is an exception implementing Omnischolar\Exception\OmnischolarException. A service that is down or asks to slow down is an UnavailableException (or RateLimitedException), never "nothing": see Errors.

Proposer une modification docs/installation.md

Symfony

Omnischolar runs without a framework (Installation); in a Symfony application its bundle does the wiring. The bundle lives in this package (Omnischolar\Bridge\Symfony), not in omnibridge/symfony. Its components - symfony/config, symfony/dependency-injection, symfony/http-kernel (6.4, 7 or 8) - are not required by omnisense/omnischolar: 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 [
    // ...
    Omnischolar\Bridge\Symfony\OmnischolarBundle::class => ['all' => true],
];

Configuration

# config/packages/omnischolar.yaml
omnischolar:
    sources:                       # by name: a factory and its options
        openalex: { factory: openalex, options: { mailto: '%env(OMNISCHOLAR_MAILTO)%', api_key: '%env(default::OPENALEX_API_KEY)%' } }
        hal: { factory: hal }
        droit: { factory: hal, options: { domains: [shs.droit] } }
        crossref: { factory: crossref, options: { mailto: '%env(OMNISCHOLAR_MAILTO)%' } }
        orcid: { factory: orcid }
        openlibrary: { factory: openlibrary, options: { mailto: '%env(OMNISCHOLAR_MAILTO)%' } }
    merger:
        preferences:               # field => sources, "default" for the others
            default: [crossref, openalex, hal]
            abstract: [hal, openalex]
        editions: true             # group the editions of a book
    export:
        abstracts: false           # write the abstracts into the exports

The whole tree, from OmnischolarBundle::configure():

Key Type Default
omnischolar.sources map, keyed by the source's name {} the sources the application uses; one factory may serve several names (hal, and droit filtered on shs.droit)
omnischolar.sources.<name>.factory string, required, not empty - openalex, hal, crossref, orcid, openlibrary, arxiv, inspire, or an application's own factory's getName()
omnischolar.sources.<name>.options any value (a map) [] handed as they are to the factory's create() (Configuration)
omnischolar.merger.preferences map: field => list of source names {} each field given replaces that field's default order; the others keep theirs (Merging)
omnischolar.merger.editions bool true group the editions of a book under one record
omnischolar.export.abstracts bool false write the abstracts into BibTeX, RIS and CSL-JSON

The options are not validated when the container compiles but when the source is built.

Services

Service id Class Autowiring
Omnischolar\Registry Registry Registry $registry every configured source by name. Public.
omnischolar.source.<name> SourceInterface SourceInterface $<name> one source, built by Registry::get('<name>') the first time it is injected
Omnischolar\Merger Merger Merger $merger with merger.preferences and merger.editions
Omnischolar\Collector Collector Collector $collector over the registry and that merger
Omnischolar\Export Export Export $export with export.abstracts
Omnischolar\OpenAlex\OpenAlexSourceFactory, ...\Hal\HalSourceFactory, ...\Crossref\CrossrefSourceFactory, ...\Orcid\OrcidSourceFactory, ...\OpenLibrary\OpenLibrarySourceFactory, ...\Arxiv\ArxivSourceFactory, ...\Inspire\InspireSourceFactory 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); tagged omnischolar.source_factory
use Omnischolar\Collector;
use Omnischolar\Export;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class PublicationsController extends AbstractController
{
    #[Route('/publications.bib')]
    public function bibtex(Collector $collector, Export $export): Response
    {
        $works = $collector->collect(['openalex' => 'A5108007452', 'hal' => 'Keitaro Nakatani']);

        return new Response($export->bibtex($works), 200, ['Content-Type' => Export::contentType('bibtex')]);
    }
}

SourceInterface $droit is the source configured as droit (HAL on shs.droit): the argument's name selects it. Every source implements the whole SourceInterface, so the one alias per source is enough; what a source does not do is a NotSupportedException when it is asked.

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 Omnischolar\Source\SourceFactoryInterface (usually by extending Omnischolar\Source\SourceFactory) is autoconfigured: tagged omnischolar.source_factory, it joins the registry, and its getName() can be named as a factory (Writing a provider).

The bundle registers no command, event, Twig function or route. Cache what the sources answer (they are remote, and some count their calls), except when $collector->incomplete is not empty.

Tests/Bridge/OmnischolarBundleTest.php boots a kernel with the bundle: the seven installed factories and an autoconfigured one of the test's own registered, sources injected by their argument's name, the collector merging with the configured preferences, abstracts exported as configured.

Proposer une modification docs/symfony.md

Laravel

omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnischolar provider. None is needed: the registry, the merger, the collector and the export are plain PHP, and a service provider of the application builds them once, from a config file.

// config/omnischolar.php
return [
    'sources' => [
        'openalex' => ['factory' => 'openalex', 'options' => ['mailto' => env('OMNISCHOLAR_MAILTO'), 'api_key' => env('OPENALEX_API_KEY')]],
        'hal' => ['factory' => 'hal'],
        'crossref' => ['factory' => 'crossref', 'options' => ['mailto' => env('OMNISCHOLAR_MAILTO')]],
    ],
    'merger' => ['preferences' => ['abstract' => ['hal', 'openalex']], 'editions' => true],
    'export' => ['abstracts' => false],
];
// app/Providers/OmnischolarServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Omnischolar\Collector;
use Omnischolar\Crossref\CrossrefSourceFactory;
use Omnischolar\Export;
use Omnischolar\Hal\HalSourceFactory;
use Omnischolar\Merger;
use Omnischolar\OpenAlex\OpenAlexSourceFactory;
use Omnischolar\Registry;
use Symfony\Component\HttpClient\HttpClient;

final class OmnischolarServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Registry::class, function (): Registry {
            $http = HttpClient::create();

            return new Registry([
                new OpenAlexSourceFactory($http),
                new HalSourceFactory($http),
                new CrossrefSourceFactory($http),
            ], config('omnischolar.sources'));
        });
        $this->app->singleton(Merger::class, fn (): Merger => new Merger(config('omnischolar.merger.preferences'), config('omnischolar.merger.editions')));
        $this->app->singleton(Collector::class, fn ($app): Collector => new Collector($app->make(Registry::class), $app->make(Merger::class)));
        $this->app->singleton(Export::class, fn (): Export => new Export(config('omnischolar.export.abstracts')));
    }
}

Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject the collector or the registry:

use Omnischolar\Collector;
use Omnischolar\Export;

Route::get('/publications.bib', function (Collector $collector, Export $export) {
    $works = $collector->collect(['openalex' => 'A5108007452', 'hal' => 'Keitaro Nakatani']);

    return response($export->bibtex($works), 200, ['Content-Type' => Export::contentType('bibtex')]);
});

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. Cache what is collected (Cache::remember()), except when $collector->incomplete is not empty. This page was written from the classes' 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. Every source implements the whole Omnischolar\Source\SourceInterface; what it cannot do throws a NotSupportedException (HttpSource throws it for each operation a provider does not override):

Method Answers
getName(): string the name of its factory: openalex, hal...
capabilities(): list<Capability> what it can be asked, and what its answers hold
author(Identifier|string $author): ?Author the researcher's profile, or null when unknown
works(Identifier|string $author, ?Query $query = null): Page an author's works, one page at a time, newest first unless the query sorts otherwise
work(Identifier|string $id): ?Work one work, or null
search(Query $query): Page works matching the query, one page at a time

An author or a work is named by an Identifier, or a string the source reads: one of its own identifiers ('A5108007452', 'OL7092953A', 'keitaro-nakatani', 'Juan.M.Maldacena.1'), a resolver URL (https://orcid.org/..., https://doi.org/...), or - for the sources that search by name (HAL, Crossref, Open Library, arXiv, INSPIRE) - a name. Unknown is null or an empty page; a service down or asking to slow down is an UnavailableException; an identifier the source cannot read is a NotSupportedException (Errors).

Capability (string-backed):

Case Value
AUTHOR author author(): a profile
WORKS works works(): an author's works, page by page
WORK work work(): one work by an identifier
SEARCH search search(): works by text, title, author, year, type
METRICS metrics an author's counts: works, citations, h-index
CITATIONS citations each work's citation count
ABSTRACTS abstracts
OPEN_ACCESS open_access open-access status and full-text links
COVERS covers book covers
AFFILIATIONS affiliations a CV: positions, degrees, distinctions
DOMAINS domains filtering by subject domain (Query::$domains)

A factory (SourceFactoryInterface: getName(), create(array $options = [])) builds a source; Omnischolar\Registry holds them by name (Configuration).

Query and Page

Omnischolar\Source\Query (final readonly) is what works() and search() are asked:

Parameter Type Default
text ?string null free text (INSPIRE: its own query language)
title ?string null words of the title
author ?string null an author's name (search())
from, to ?int null years, inclusive; either end may be open
types list of WorkType [] only these kinds of works
domains list of strings [] subject codes: a HAL domain (shs.droit), an arXiv or INSPIRE category (hep-th)
openAccess ?bool null only open-access works (true), or only closed ones (false)
limit int 25 works per page; each source caps it
cursor ?string null the Page::$next of the page before
sort string Query::NEWEST newest, oldest (Query::OLDEST), relevance (Query::RELEVANCE), cited (Query::CITED)

A source applies the criteria it understands and ignores the others, except domains, which a source without Capability::DOMAINS refuses (NotSupportedException) rather than answer unfiltered (Sources lists what each applies).

  • with(array $changes): Query - a copy: $query->with(['cursor' => $page->next]).
  • hasYears(): bool - from or to is set.
  • inYears(?int $year): bool - whether a year falls in the range (an unknown year does only when there is no range).

Omnischolar\Source\Page is one page, iterable and countable:

Property Type Default
works list of Work []
next ?string null the cursor of the next page; null on the last one
total ?int null how many works the whole query matches, when the source says

isLast(): bool.

Models

All are final readonly classes in Omnischolar\Model, built with named arguments. Dates are ISO 8601 strings as precise as the source knows them: 2024, 2024-09, 2024-09-25.

Work

Property Type Default
title string required
type WorkType OTHER
authors list of Contributor [] in the order printed, editors included (their role says so)
year ?int null
date ?string null ISO 8601, as precise as known
subtitle ?string null
venue ?Venue null where it appeared
publisher ?string null
volume, issue, pages, edition ?string null
abstract ?string null plain text
language ?string null
openAccess ?bool null
url ?string null the landing page
pdfUrl ?string null the full text, open access
license ?string null
cover ?string null a cover image's address (Open Library)
citations ?int null the count of the source it came from
keywords list of strings []
domains list of strings [] HAL domains, arXiv / INSPIRE categories
identifiers Identifiers empty
source ?string null the source of the record
sources list of strings [] after a merge, every source merged into it
editions list of Work [] a book's editions, newest first (Merger)
note ?string null arXiv's journal reference or comment
Method
with(array $changes): Work a copy with some fields changed: $work->with(['cover' => $url])
fullTitle(): string title: subtitle, unless the title already holds the subtitle
id(Scheme): ?string, doi(): ?string, isbns(): list<string> its identifiers
authorsOnly(), editors(): list of Contributor by role
firstAuthor(): ?Contributor the first author, else the first contributor
link(): ?string the best link to read it: the PDF, else the landing page, else the DOI's

WorkType (string-backed; label() gives the English label):

Case Value label()
ARTICLE article Article a journal article (a review article is one)
BOOK book Book a book, or a whole edited volume
CHAPTER chapter Book chapter a chapter, an encyclopedia entry
CONFERENCE conference Conference paper a conference paper, a talk, a poster
PREPRINT preprint Preprint a preprint, a working paper
THESIS thesis Thesis a doctoral thesis, a habilitation, a dissertation
REPORT report Report
DATASET dataset Dataset
SOFTWARE software Software
REVIEW review Review a book review, a peer review
EDITORIAL editorial Editorial an editorial, a letter, an erratum, a cover
OTHER other Other

WorkType::fromName(?string $name) reads a Crossref, CSL, OpenAlex or ORCID type name (journal-article, book-chapter, proceedings-article, posted-content, dissertation...; _ and spaces read as -); an unknown name is OTHER.

Contributor

A name on a work.

Property Type Default
name string required as the source prints it
given, family ?string null
identifiers Identifiers empty ORCID, idHAL, OpenAlex, BAI...
role string Contributor::AUTHOR author, editor (EDITOR), translator (TRANSLATOR), or HAL's own code
affiliations list of strings [] the institutions as printed on the work
  • Contributor::fromName(string $name, ?Identifiers $identifiers = null, string $role = AUTHOR)
    • Nakatani, Keitaro or Keitaro Nakatani: the family name from the comma, else the last word.
  • familyName(): string - the family name, or the last word of the name.
  • sortName(): string - Nakatani, Keitaro, as a bibliography sorts it.
  • orcid(): ?string.

Venue

Property Type Default
name string required
type string Venue::JOURNAL journal, conference (CONFERENCE), book (BOOK), book_series (BOOK_SERIES), repository (REPOSITORY), other (OTHER)
issn list of strings [] print and electronic, normalised (2041-6520)
publisher ?string null
abbreviation ?string null
identifiers Identifiers empty OpenAlex's source id
url ?string null

Author

A researcher's profile, as one source knows it.

Property Type Default
name string required
given, family ?string null
identifiers Identifiers empty
alternativeNames list of strings [] how the name is also spelt
affiliations list of Affiliation [] positions, degrees, distinctions; or institutions seen on the works
metrics ?Metrics null
urls list of strings [] their pages
biography ?string null
keywords list of strings [] research topics
country ?string null
source ?string null

affiliations(string $kind): list<Affiliation> (those of one kind), orcid(): ?string.

Affiliation

A line of a CV. Dates as precise as the source knows them (2001, 1930-02).

Property Type Default
organization string required
kind string Affiliation::EMPLOYMENT employment, education (EDUCATION), qualification (QUALIFICATION), invited-position (INVITED_POSITION), distinction (DISTINCTION), membership (MEMBERSHIP), service (SERVICE), observed (OBSERVED: an institution seen on the works, without a role nor dates the source vouches for)
role, department ?string null
start, end ?string null
city, country ?string null
ror ?string null the organisation's ROR
url ?string null
years list of ints [] for OBSERVED: the years the institution appears on the works
current ?bool null

isCurrent(): bool - current when the source says, else a start and no end.

Metrics

An author's counts, as one source computes them: they differ from one source to the next.

Property Type Default
works, citations, hIndex, i10Index ?int null
twoYearMeanCitedness ?float null
byYear array<int, array{works?: ?int, citations?: ?int}> [], oldest first
source ?string null

Identifiers

Omnischolar\Model\Identifier normalises an identifier so that two spellings compare equal, and checks it on its shape (and checksum) without the network:

Identifier::parse('https://doi.org/10.1039/D4SC04973J');   // doi:10.1039/d4sc04973j
Identifier::parse('1-4129-9660-0');                         // isbn:9781412996600
Identifier::parse('arXiv:hep-th/9711200v3');                // arxiv:hep-th/9711200
Identifier::parse('bai:Juan.M.Maldacena.1');                // inspire_bai:Juan.M.Maldacena.1
Identifier::of('idhal', 'keitaro-nakatani');
Identifier::tryOf(Scheme::ORCID, '0000-0002-1825-0098');    // null: bad checksum
Scheme (Scheme::) Value label() Normalised as url()
DOI doi DOI lower case, without https://doi.org/ or doi: https://doi.org/<doi>
ISBN isbn ISBN ISBN-13, checksum checked; an ISBN-10 converted (isbn10() gives it back for a 978 ISBN) -
ISSN issn ISSN NNNN-NNNC, checksum checked -
ARXIV arxiv arXiv without its version: 2401.12345, hep-th/9711200 https://arxiv.org/abs/<id>
HAL hal HAL a deposit without its version: hal-04772417 https://hal.science/<id>
IDHAL idhal idHAL a HAL author, lower case: keitaro-nakatani https://cv.hal.science/<idhal>
ORCID orcid ORCID 0000-0000-0000-000X, checksum checked https://orcid.org/<orcid>
OPENALEX openalex OpenAlex W... (work), A... (author), S... (source), I... (institution)... upper case https://openalex.org/<id>
INSPIRE inspire INSPIRE a record number -
INSPIRE_BAI inspire_bai INSPIRE BAI an author's BAI as written: Juan.M.Maldacena.1 INSPIRE's author search
OPENLIBRARY openlibrary Open Library OL...W (work), OL...M (edition), OL...A (author) the work's, edition's or author's page
PMID pmid PubMed the number https://pubmed.ncbi.nlm.nih.gov/<id>/
PMCID pmcid PubMed Central PMC... https://www.ncbi.nlm.nih.gov/pmc/articles/<id>/

parse() reads scheme:value (also ol:, bai:, isbn10:, isbn13:), the resolver URLs of each service (doi.org, orcid.org, arxiv.org, openalex.org, inspirehep.net, openlibrary.org, pubmed, hal.science and archives-ouvertes.fr), and the values whose shape tells their scheme (a 10. DOI, an ORCID, W/A followed by six digits or more, OL..., PMC..., an arXiv id, a HAL id, an ISBN, an ISSN); a bare number (an INSPIRE record? a PubMed id?) needs its scheme.

Method
Identifier::of(Scheme|string, string) normalised, or InvalidArgumentException ("x" is not a valid ORCID.); PHP's ValueError for an unknown scheme name
Identifier::tryOf(Scheme|string|null, ?string) the same, null instead
doi(), isbn(), arxiv(), hal(), orcid(), openalex(), inspire(), openlibrary() of() with that scheme
key(): string scheme:value: what the Merger and the collections key on; also __toString()
equals(Identifier): bool, url(): ?string, isbn10(): ?string

Identifiers keeps each identifier once, in the order learnt (nulls skipped): get(Scheme), value(Scheme), all(?Scheme), values(Scheme), has(Scheme|Identifier), with(...), merge(Identifiers), keys(), toArray() / Identifiers::fromArray() for storage; iterable and countable.

Proposer une modification docs/models.md

Sources

Every source implements Omnischolar\Source\SourceInterface:

Method Answers
getName() its factory's name: openalex, hal...
capabilities() a list of Capability: what it can be asked, what its answers hold
author($id) an Author, or null
works($author, ?Query) a Page of Work, newest first by default; $page->next is the next cursor
work($id) a Work, or null
search(Query) a Page of Work

What each one does

openalex hal crossref orcid openlibrary arxiv inspire
author() by OpenAlex A..., ORCID idHAL, ORCID - ORCID OL...A, name - INSPIRE record, BAI, ORCID
works() by OpenAlex A..., ORCID idHAL, ORCID, name ORCID, name ORCID OL...A, name name BAI, record, ORCID, name
work() by OpenAlex W..., DOI, PMID, PMCID HAL id, DOI, arXiv DOI <orcid>/work/<put-code> ISBN, OL...M, OL...W arXiv INSPIRE record, DOI, arXiv
search() text, title, author text, title, author text, title, author - text, title, author text, title, author INSPIRE query, title, author
Metrics (h-index...) yes - - - - - yes (citation summary)
Citations per work yes - yes - - - yes
Abstracts yes (rebuilt) yes some (JATS) some some yes yes
Open access, full text yes yes (deposited PDF) - - - yes (PDF) via arXiv
Covers - - - - yes - -
CV (Affiliation) institutions seen on the works - - employments, education, distinctions... - - positions
Query::$domains refused HAL domains (shs.droit) refused refused refused arXiv categories arXiv categories
Paging (cursor) OpenAlex cursor Solr cursorMark offset offset page offset page
Per page, at most 100 10 000 1 000 100 100 2 000 1 000

What the services do not allow

  • OpenAlex: no lookup by arXiv id; dates known by the year only come as January 1st; the keyless daily budget is limited (a free key raises it tenfold). No search by subject domain here.
  • HAL: no citation counts; an author's ORCID per name is not aligned in the search answers (the work's contributors carry their idHAL, not their ORCID); works() by ORCID finds only the deposits where the ORCID was linked; by name, the exact form signed.
  • Crossref: no author profiles; works by name come from a fuzzy author query, filtered on the family name and first given name (or initial); cursors cannot sort by date, so pages are offsets (10 000 deep at most); no open-access status.
  • ORCID: only what the researcher made public; no search of works; works come all at once (filtered and paged here), their details one page per call.
  • Open Library: books only; a work's editions cost one more call each; publication dates are free text; no abstracts for most editions.
  • arXiv: no author profiles nor author identifiers: by name only; one call every three seconds; the DOI and journal reference only when the authors added them.
  • INSPIRE-HEP: physics only; search by ORCID goes through the author's BAI; collaboration papers carry thousands of authors (all are read).

Throttling and politeness

Source Default throttle Contact
openalex none mailto sent as a parameter
crossref none mailto as a parameter and in the User-Agent (polite pool)
openlibrary 0.4 s mailto in the User-Agent
arxiv 3 s mailto in the User-Agent
inspire 0.35 s mailto in the User-Agent
hal, orcid none User-Agent

Every factory takes mailto, user_agent and base_uri; throttle (seconds between two calls of one source instance) is read by every source: none by default, but for arXiv, INSPIRE and Open Library (Configuration).

Writing a source

A source of another service: Writing a provider.

Proposer une modification docs/sources.md

Merging

$merger = new Merger();                                   // the default preferences
$works = $merger->merge($openalexWorks, $halWorks, $crossrefPage);   // lists or pages, newest first
$merger->same($a, $b);                                    // would they be one record?

When two records are one work

In this order:

  1. the same DOI;
  2. the same ISBN - for books, theses, reports and other works only: a chapter or a conference paper carries the ISBN of its book;
  3. the same arXiv id;
  4. the same HAL id;
  5. failing all identifiers, the same title in the same year - compared in lower case, without accents nor punctuation, with and without its subtitle (when the title alone is long enough). A preprint's title also matches the year after its own, when it is often published.

A stronger identifier that differs keeps two records apart:

  • matched by ISBN, arXiv or HAL, two records that both carry a DOI and share none are two works;
  • matched by title, two records that both carry a DOI, an ISBN, an arXiv id or a HAL id, and share none of them, are two works (two Zenodo versions of one dataset, two editions of a book).

A record that matches two groups joins them (an OpenAlex record with the HAL id of one and the arXiv id of the other), unless the two groups carry different DOIs or arXiv ids.

Each field from its preferred source

For each field, the sources are ranked; the first record of the group that has a value gives it. "default" ranks the fields not named; a source not listed comes after those listed.

Field Default order
default crossref, openalex, hal, inspire, arxiv, orcid, openlibrary
abstract hal, openalex, arxiv, inspire, crossref
citations openalex, inspire, crossref
cover openlibrary
pdfUrl hal, arxiv, openalex
license openalex, crossref, hal
date crossref, hal, inspire, arxiv, orcid, openlibrary, openalex (OpenAlex dates a year-only work on January 1st)
new Merger([
    'default' => ['hal', 'crossref', 'openalex'],   // a French lab: HAL first
    'abstract' => ['hal'],
    'citations' => ['inspire', 'openalex'],
]);

Some fields follow their own rule:

  • title and subtitle come together, from the record preferred for title;
  • type: the first that is not other (a published article over its preprint, by the default order);
  • year, then date from the same record, or a date of the same year;
  • authors: the preferred list, each name completed with the identifiers (ORCID, idHAL...) the other records give the same family name - when that name occurs once in each list;
  • identifiers, keywords, domains, sources are united; openAccess is true when one record says so.

Editions

After merging, books with the same title (without its subtitle) and the same first author's family name, and no ISBN in common, are the editions of one book. The group is one record - the newest edition's, its missing fields (subtitle, abstract, cover, publisher, language, URL, citations) taken from the others - with every ISBN of every edition, and the editions themselves in $editions, newest first. new Merger([], false) leaves them apart.

Merging a merged list again (a weekly sync) takes the groups apart and groups them the same way. The exports cite a group by its newest edition.

Open Library's two volumes of the Méthode de Singapour CE1 stay two books: their titles differ by their volume number.

Proposer une modification docs/merging.md

Export

$export = new Export();                     // new Export(abstracts: true) to include them
$export->bibtex($works);
$export->ris($work);
$export->cslJson($works);                   // a JSON string
$export->csl($works);                       // the same as arrays
$export->format('bibtex', $works);          // bibtex, ris, csl-json
Export::contentType('ris');                 // application/x-research-info-systems; charset=utf-8

Each takes one Work or a list. A book whose editions were grouped is cited by its newest edition.

Citation keys

The first author's family name, the year and the title's first significant word, without accents: chocron2024acid, neagoy2012planting, cotler2026logarithmic. Within one export, a key taken twice gets a, b...

BibTeX

Work type Entry
article (in a venue), review, editorial @article
book @book
chapter @incollection
conference @inproceedings
thesis @phdthesis
report @techreport
preprint, dataset, software, other @misc (arXiv: eprint, archiveprefix, primaryclass)

Fields: author, editor, title (in double braces, its capitals kept), journal, booktitle, series, year, month, volume, number, pages (--), edition, publisher, school, institution, doi, isbn, issn, eprint, hal_id, url, language, keywords, abstract, note. Special characters are escaped; accents stay UTF-8 (biblatex, or BibTeX with inputenc).

RIS

TY (JOUR, BOOK, EDBOOK, CHAP, CPAPER, UNPB, THES, RPRT, DATA, COMP, GEN), ID, AU/ED, TI, T2, J2, PY, DA, VL, IS, SP, EP, ET, PB, SN, DO, UR, L1 (the PDF), LA, KW, AB, N1, ER; lines end with CRLF.

CSL-JSON

Items for citeproc-js, citeproc-php or pandoc: type (article-journal, book, chapter, paper-conference, article for preprints with genre: Preprint and the arXiv id as number, thesis, report, dataset, software, review, document), author and editor (family/given, or literal), issued.date-parts, container-title, publisher, volume, issue, page, edition, DOI, ISBN, ISSN, URL, language, keyword, abstract, note.

Proposer une modification docs/export.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 omnischolar.sources.<name>.options (Symfony).

The registry

new Registry(iterable $factories, array $config);
// $config: ['openalex' => ['factory' => 'openalex', 'options' => ['mailto' => 'you@example.org']], ...]
Method
get(string $name): SourceInterface the source, built on the first call and kept
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
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: openalex, hal.), so is a factory not installed (No "x" factory for the "y" source; installed: openalex.). One factory may serve several names with different options (hal, and droit filtered on shs.droit).

How options are read

Omnischolar\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 or null; bool($key, $default) reads a boolean whatever its spelling (true, 1, "true", "yes", "on"; false, 0, "false", "no", "off", ""; anything else an InvalidConfigException), as a site's YAML or environment gives a string. Before build(), validateNotEmpty() refuses each key of omnischolar.required_options that is missing, '' or [] (The "x" source needs: key.); no provider requires any option today. The keys every factory sets for itself: omnischolar.factory_name and omnischolar.required_options.

Options every source takes

Option Type Default
mailto string null a contact address: added to the User-Agent (omnischolar/1.x (+https://github.com/glitchr-omnisense/omnischolar; mailto:<address>)), and sent as a parameter by OpenAlex and Crossref (their polite pools)
user_agent string omnischolar/1.x (+https://github.com/glitchr-omnisense/omnischolar) and the mailto the User-Agent of every call; when set, the mailto is not added to it
base_uri string each provider's the service's address, to point at a copy, a proxy or a sandbox
throttle float each provider's (below) seconds left between two calls of one source; 0.0 for OpenAlex, Crossref, HAL and ORCID

A factory's constructor takes the HTTP client: new OpenAlexSourceFactory(?HttpClientInterface $http = null); with none, HttpClient::create().

openalex

Option Type Default
mailto string null sent with every call as OpenAlex asks of polite callers
api_key string null a free key (openalex.org/settings/api): ten times the keyless daily budget; sent as api_key
paratext bool false true keeps covers, tables of contents and the like (is_paratext:false is filtered otherwise)
base_uri string https://api.openalex.org/

hal

Option Type Default
domains list of strings [] HAL domain codes every query is filtered on (shs.droit, chim, phys.cond...); Query::$domains adds to them
base_uri string https://api.archives-ouvertes.fr/

crossref

Option Type Default
mailto string null sent with every call and in the User-Agent: Crossref's polite pool
base_uri string https://api.crossref.org/

orcid

Option Type Default
base_uri string https://pub.orcid.org/v3.0/ the sandbox: https://pub.sandbox.orcid.org/v3.0/

openlibrary

Option Type Default
editions bool true one record per edition (the Merger groups them); false: one per work
throttle float 0.4 Open Library allows about three calls a second to a named caller
base_uri string https://openlibrary.org/

arxiv

Option Type Default
throttle float 3.0 one call every three seconds, as arXiv asks
base_uri string https://export.arxiv.org/api/

inspire

Option Type Default
throttle float 0.35 INSPIRE allows 15 calls in 5 seconds
base_uri string https://inspirehep.net/api/

paratext and editions are read with Config::bool(): 'false', '0', 'no' or 'off' (an environment variable's string) is false, a value that is no boolean an InvalidConfigException (The "openalex" source's paratext is a boolean (true or false), not "maybe".).

The Merger, the Collector, the Export

Constructor
new Merger(array $preferences = [], bool $editions = true) $preferences: field => source names in the order trusted, default for the fields not named; each field given replaces its default order (Merging)
new Collector(Registry $registry, Merger $merger = new Merger()) collect(array $profiles, ?Query $query = null, int $max = 2000): source name => an author (or a list of authors) there
new Export(bool $abstracts = false) true writes the abstracts (Export)

The bundle's omnischolar.merger and omnischolar.export set them (Symfony).

Proposer une modification docs/configuration.md

Errors

Not found is null (author(), work()) or an empty page (works(), search()). Everything else is an exception implementing Omnischolar\Exception\OmnischolarException (an interface extending \Throwable):

Exception Extends Thrown when
InvalidConfigException \LogicException a source not configured, a factory not installed, a required option missing
NotSupportedException \LogicException the source does not do that (author() of arXiv or Crossref, search() of ORCID), cannot read that identifier, or cannot filter by domain
ProviderException \RuntimeException the service answered with an error: any 4xx but 404, 410 and 429 (401 and 403 included); an answer that is not JSON or not a JSON object; arXiv's "Error" entry; an Atom answer that does not parse
UnavailableException ProviderException the service could not be reached or is down: 5xx, a transport error, a timeout
RateLimitedException UnavailableException 429: slow down, or OpenAlex's keyless daily budget spent

ProviderException and its children keep what the service said:

Property
source the source's name
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 service answered 400: <the body's first 300 characters, tags stripped>, unreachable: <transport error>, rate limited, retry in 30 s
getPrevious() the HTTP client's exception, for a transport error

The messages of NotSupportedException: The "crossref" source does not know authors., The "orcid" source does not search., The "openalex" source does not filter by domain., The "arxiv" source cannot look up "x"..

How HTTP becomes an answer or an exception

HttpSource (every provider) maps each answer the same way:

Answer Result
2xx the body
404, 410 null: not found (an empty page for a list)
429 RateLimitedException, Retry-After kept
5xx UnavailableException
any other 4xx ProviderException
a transport error, a timeout UnavailableException

What to catch

use Omnischolar\Exception\ProviderException;
use Omnischolar\Exception\RateLimitedException;
use Omnischolar\Exception\UnavailableException;

try {
    $page = $registry->get('openalex')->works('A5108007452');
} catch (RateLimitedException $e) {
    $retry = $e->retryAfter;                                   // later, or with an api_key
} catch (UnavailableException) {
    // down: show what was cached, never "no publications", and cache nothing
} catch (ProviderException $e) {
    $logger->error($e->getMessage(), ['status' => $e->status]);
}

Collector::collect() catches UnavailableException (and so RateLimitedException) and NotSupportedException for each source: the source is skipped, named in $collector->incomplete with the message, and the others still answer. A ProviderException that is not an UnavailableException is not caught: it reaches the caller.

InvalidConfigException and NotSupportedException are programming errors (LogicExceptions): test capabilities() rather than catch them.

Outside the family

  • InvalidArgumentException: Identifier::of() and its shortcuts with a value that is not of the scheme; Export::format() with a format that is not one (No "x" export; there are bibtex, ris, csl-json.).
  • ValueError: Identifier::of() with a scheme name that is not a Scheme.
  • JsonException: Export::cslJson() on a value JSON cannot encode (an invalid UTF-8 string).

No secret in a failure

A source keeps its key out of what HttpSource throws: in the query (OpenAlex's api_key) or a header (a bearer), it is 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 and headers stood (zend.exception_ignore_args off).

Proposer une modification docs/errors.md

Testing

Testing an application that lists publications through omnischolar, 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/StubSourceFactory.php) 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; the Merger and the Export need no network at all.

A provider on a MockHttpClient

Every factory takes its HTTP client. Given Symfony's MockHttpClient, the provider runs whole - its requests, its mapping, its errors - and nothing leaves the machine:

<?php

use Omnischolar\Crossref\CrossrefSourceFactory;
use Omnischolar\Exception\UnavailableException;
use Omnischolar\Export;
use Omnischolar\Registry;
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: a DOI as a RIS record to download, or null when Crossref is down. */
final class Cite
{
    public function __construct(private readonly Registry $registry, private readonly Export $export = new Export())
    {
    }

    public function ris(string $doi): ?string
    {
        try {
            $work = $this->registry->get('crossref')->work($doi);
        } catch (UnavailableException) {
            return null;
        }

        return null === $work ? null : $this->export->ris($work);
    }
}

final class CiteTest extends TestCase
{
    private function registry(MockHttpClient $http): Registry
    {
        return new Registry([new CrossrefSourceFactory($http)], ['crossref' => ['factory' => 'crossref', 'options' => ['mailto' => 'test@example.org']]]);
    }

    public function testADoiBecomesARisRecord(): void
    {
        $answer = new JsonMockResponse(['message' => [
            'DOI' => '10.1039/D4SC04973J', 'type' => 'journal-article',
            'title' => ['Acid-sensitive photoswitches'], 'container-title' => ['Chemical Science'],
            'author' => [['given' => 'Keitaro', 'family' => 'Nakatani']],
            'issued' => ['date-parts' => [[2024, 9, 25]]], 'volume' => '15', 'page' => '16034-16039',
        ]]);
        $ris = (new Cite($this->registry(new MockHttpClient($answer))))->ris('https://doi.org/10.1039/D4SC04973J');

        self::assertStringStartsWith("TY  - JOUR\r\nID  - nakatani2024acid\r\nAU  - Nakatani, Keitaro\r\nTI  - Acid-sensitive photoswitches\r\n", (string) $ris);
        self::assertStringContainsString("DO  - 10.1039/d4sc04973j\r\n", (string) $ris);
        self::assertSame('https://api.crossref.org/works/10.1039/d4sc04973j?mailto=test%40example.org', $answer->getRequestUrl());
    }

    public function testNothingWhenCrossrefIsDown(): void
    {
        self::assertNull((new Cite($this->registry(new MockHttpClient(new MockResponse('', ['http_code' => 503])))))->ris('10.1039/d4sc04973j'));
    }
}
$ vendor/bin/phpunit --testdox tests/
Cite
 ✔ A doi becomes a ris record
 ✔ Nothing when crossref is down

OK (2 tests, 4 assertions)

(run on 2026-10-26 in the harness's image, PHPUnit 11.5.57, no network)

  • Each provider's Tests/Fixtures holds answers recorded from the real service on 2026-10-04 to start from (OpenAlex's Keitaro Nakatani, INSPIRE's Juan Maldacena, ORCID's Josiah Carberry...); the harness keeps one OpenAlex answer in docker/harness/recorded/.
  • Give arXiv, INSPIRE and Open Library throttle: 0 in a test: their default throttle sleeps between two calls.
  • A 503 tests "down" (UnavailableException), a 429 with Retry-After the slowed path, a 404 "not found".

A source of the application's own

For code that only needs works back, a class extending HttpSource (or implementing SourceInterface whole) that answers from memory, given to the registry through a factory - the core's own Tests/StubSourceFactory.php is such a double, to copy rather than depend on. The Collector then runs on it as on any source: its incomplete list is tested by making the source throw an UnavailableException.

Merger and Export

Both are plain objects: build Works by hand and check what comes out.

$a = new Work('Cascade Fluorescence Modulation', WorkType::ARTICLE, year: 2024, identifiers: Identifiers::of(Identifier::doi('10.1021/acsami.4c09186')), source: 'openalex', citations: 5);
$b = new Work('Cascade fluorescence modulation', WorkType::ARTICLE, year: 2024, identifiers: Identifiers::of(Identifier::doi('10.1021/ACSAMI.4C09186'), Identifier::hal('hal-04722222')), source: 'hal');

$merged = (new Merger())->merge([$a], [$b]);
// one Work: sources ['openalex', 'hal'], the DOI and the HAL id, citations 5 (OpenAlex's)

Symfony

In a functional test, the registry is the public service Omnischolar\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). Not run in an application for this page.

The family's own tests

The workspace (~/Sites/omnischolar, every package from its checkout) runs 102 tests, 505 assertions (Verified, and not):

Test case What it checks
RegistryTest (4) sources built once by name; a required option; the Collector reading every page, merging and naming what it skipped; Collector::all() stopping at $max
IdentifierTest (33) 31 spellings parsed; normalisation (and checksums) making spellings equal; a collection keeping each once
MergerTest (9) one work per DOI, each field from its preferred source; configurable preferences; the order of identifiers (a stronger one that differs keeps works apart); a preprint merged into its article; a record bridging two groups; the editions of a book grouped; an ORCID lent to the same name; newest first; the fingerprint
ExportTest (5) BibTeX; its escapes and unique keys; a grouped book cited by its newest edition; RIS; CSL-JSON
OmnischolarBundleTest (1) a kernel with the bundle: factories registered, sources by argument name, the merger's preferences, the export's option
BareTest (3) bare --recorded --json in a process of its own: no framework class loaded, works from a recorded answer; loading the bundle does name the framework
OpenAlexSourceTest (10), HalSourceTest (7), OrcidSourceTest (7), CrossrefSourceTest (6), OpenLibrarySourceTest (6), ArxivSourceTest (6), InspireSourceTest (5) each provider on MockHttpClient and its fixtures (their manuals)

Proposer une modification docs/testing.md

The Docker harness

docker/ runs this package with every omnischolar/* source installed - from GitHub (branch 1.x), or from the checkouts beside this one when OMNISCHOLAR_PLUGINS=../.. is set in docker/.env - and a console that asks the real services.

cd docker && cp .env.dist .env       # OMNISCHOLAR_MAILTO, an OpenAlex key if you have one
docker compose run --rm omnischolar sources
Command
sources the sources installed and configured, and what each can do
author <source> <author> a profile (JSON)
works <source> <author> an author's works, every page up to --max
work <source> <id> one work (JSON)
search <source> [text] --title, --author
merge <source:author>... several sources merged; --no-editions
bare plain PHP: the registry built by hand, an author's works asked of OpenAlex, what PHP loaded
test every package's tests

works, search and merge take --from, --to, --type (repeatable), --domain (repeatable), --sort (newest, oldest, relevance, cited), --limit (per page), --max, and --format (table, json, bibtex, ris, csl-json).

The configured sources are openalex, hal, droit (HAL filtered on shs.droit), crossref, orcid, openlibrary, arxiv, inspire.

docker compose run --rm omnischolar works openalex A5108007452 --from 2024 --to 2024
docker compose run --rm omnischolar works openlibrary "Monica Neagoy"
docker compose run --rm omnischolar merge openlibrary:"Monica Neagoy" crossref:"Monica Neagoy"
docker compose run --rm omnischolar merge openalex:A5108007452 hal:"Keitaro Nakatani" --from 2024 --format bibtex
docker compose run --rm omnischolar search droit "responsabilité civile" --from 2024
docker compose run --rm omnischolar author inspire bai:Juan.M.Maldacena.1
docker compose run --rm omnischolar work crossref 10.1039/d4sc04973j

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 what it can do, asks OpenAlex for the works of A5108007452 in 2024 - the real service, or with --recorded the answer 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 omnischolar bare
Omnischolar in bare PHP: the registry built by hand, no bundle, no container.

  openalex     author works work search metrics citations abstracts open_access affiliations
  hal          author works work search abstracts open_access domains
  droit        author works work search abstracts open_access domains
  crossref     works work search citations abstracts
  orcid        author works work affiliations abstracts
  openlibrary  author works work search covers
  arxiv        works work search abstracts open_access domains
  inspire      author works work search metrics citations abstracts affiliations domains

OpenAlex, the works of A5108007452 (Keitaro Nakatani) in 2024, asked of api.openalex.org:
  2024-10-04  article  Complete kinetic and photochemical characterization of the multi-step photochromic reaction of donor–acceptor Stenhouse adducts - Physical Chemistry Chemical Physics
  2024-09-25  article  Cascade Fluorescence Modulation in Photochromic Microcapsules - ACS Applied Materials & Interfaces
  2024-09-03  article  Tuning the Thermal Stability of Tetra ‐ o– chloroazobenzene Derivatives by Transforming Push‐Pull to Push‐Push Systems - Chemistry - A European Journal
  2024-01-01  article  Acid-sensitive photoswitches: towards catalytic on-demand release of stored light energy - Chemical Science

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 intl and Composer; the harness's packages live in the harness volume of the omnischolar-harness project.

Proposer une modification docs/harness.md

Writing a provider

A provider is a package of its own, omnischolar/<service>, requiring omnisense/omnischolar and symfony/http-client, with:

  • a factory extending Omnischolar\Source\SourceFactory: populate(Config) sets its name (omnischolar.factory_name), its required options (omnischolar.required_options) and the defaults of the others; build(Config) returns the source on $this->http (the application's client, or HttpClient::create() when null). SourceFactory::userAgent($c) gives the User-Agent to send, with the mailto; pass throttle on when the service asks for an interval;
  • a source extending Omnischolar\Source\HttpSource, overriding the operations its service does (author(), works(), work(), search()) - the others throw NotSupportedException already;
  • capabilities() true to the code: the operations it overrides and what its answers hold (METRICS, CITATIONS, ABSTRACTS, OPEN_ACCESS, COVERS, AFFILIATIONS, DOMAINS);
  • 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) headers sent with every call; seconds between two calls
getJson(string $path, array $query = [], array $headers = []): ?array a GET of JSON; null on 404 or 410
get(string $path, array $query = [], array $headers = []): ?string a GET of text (Atom, XML)
identify(Identifier|string $id, Scheme ...$schemes): ?Identifier the identifier when it is of one of those schemes (a string whose shape tells no scheme is tried against each in turn: an INSPIRE record number, an idHAL); null otherwise - a name, for the sources that search by name
text(?string $text): ?string tags stripped (JATS, HTML), entities decoded, white space collapsed
date($year, $month = null, $day = null): ?string 2024, 2024-09, 2024-09-25 from date parts; null without a numeric year

A path starting with http is used as it is, else appended to $baseUri. A query value that is a list repeats its key (Solr's fq=...&fq=...), null and '' are left out, booleans are true/false. The errors are mapped once, for every source: Errors.

A whole provider

A source of a bibliography service that reads a work by DOI and searches titles:

<?php

namespace Acme\Dblp;

use Omnischolar\Config;
use Omnischolar\Source\SourceFactory;
use Omnischolar\Source\SourceInterface;
use Symfony\Component\HttpClient\HttpClient;

final class DblpSourceFactory extends SourceFactory
{
    protected function populate(Config $c): void
    {
        $c->defaults([
            'omnischolar.factory_name' => 'dblp',
            'omnischolar.required_options' => [],
            'throttle' => 1.0,
            'base_uri' => 'https://dblp.example/api/',
        ]);
    }

    protected function build(Config $c): SourceInterface
    {
        return new DblpSource($this->http ?? HttpClient::create(), (string) $c->get('base_uri'), ['User-Agent' => self::userAgent($c)], (float) $c->get('throttle', 1.0));
    }
}
<?php

namespace Acme\Dblp;

use Omnischolar\Exception\NotSupportedException;
use Omnischolar\Model\Contributor;
use Omnischolar\Model\Identifier;
use Omnischolar\Model\Identifiers;
use Omnischolar\Model\Scheme;
use Omnischolar\Model\Venue;
use Omnischolar\Model\Work;
use Omnischolar\Model\WorkType;
use Omnischolar\Source\Capability;
use Omnischolar\Source\HttpSource;
use Omnischolar\Source\Page;
use Omnischolar\Source\Query;

final class DblpSource extends HttpSource
{
    public function getName(): string
    {
        return 'dblp';
    }

    public function capabilities(): array
    {
        return [Capability::WORK, Capability::SEARCH];
    }

    public function work(Identifier|string $id): ?Work
    {
        $doi = self::identify($id, Scheme::DOI) ?? throw NotSupportedException::identifier('dblp', (string) $id);
        $data = $this->getJson('publ/doi/'.$doi->value);       // 404: null

        return null === $data ? null : $this->toWork($data);
    }

    public function search(Query $query): Page
    {
        if ($query->domains) {
            throw NotSupportedException::operation('dblp', 'filter by domain');
        }
        $offset = max(0, (int) $query->cursor);
        $limit = max(1, min(1000, $query->limit));
        $data = $this->getJson('publ/search', ['q' => trim(($query->title ?? '').' '.($query->text ?? '')), 'h' => $limit, 'f' => $offset]) ?? [];
        $works = array_map($this->toWork(...), $data['hits'] ?? []);
        $works = array_values(array_filter($works, static fn (Work $w) => $query->inYears($w->year)));
        $total = $data['total'] ?? null;

        return new Page($works, null !== $total && $offset + $limit < $total ? (string) ($offset + $limit) : null, $total);
    }

    private function toWork(array $d): Work
    {
        return new Work(
            title: self::text($d['title'] ?? null) ?? '',
            type: WorkType::fromName($d['type'] ?? null),
            authors: array_map(static fn (string $name) => Contributor::fromName($name), $d['authors'] ?? []),
            year: isset($d['year']) ? (int) $d['year'] : null,
            date: self::date($d['year'] ?? null),
            venue: isset($d['venue']) ? new Venue((string) $d['venue'], Venue::CONFERENCE) : null,
            identifiers: Identifiers::of(Identifier::tryOf(Scheme::DOI, $d['doi'] ?? null)),
            source: 'dblp',
        );
    }
}

Used on a MockHttpClient that answers a search and a DOI:

$registry = new Registry([new DblpSourceFactory($http)], ['dblp' => ['factory' => 'dblp', 'options' => ['throttle' => 0]]]);
$dblp = $registry->get('dblp');

foreach ($dblp->search(new Query(title: 'attention', from: 2017)) as $work) {
    echo $work->year, ' ', $work->type->value, ' ', $work->title, ' - ', $work->venue->name, ' ', $work->doi(), "\n";
}
echo (new Export())->bibtex($dblp->work('https://doi.org/10.5555/3295222.3295349'));
$dblp->author('Ashish Vaswani');           // NotSupportedException
2017 conference Attention Is All You Need - NeurIPS 10.5555/3295222.3295349
@inproceedings{vaswani2017attention,
  author = {Vaswani, Ashish},
  title = {{Attention Is All You Need}},
  booktitle = {NeurIPS},
  year = {2017},
  doi = {10.5555/3295222.3295349}
}
The "dblp" source does not know authors.

(run on 2026-10-26 in the harness's image, no network)

Rules the existing providers keep

  • Unknown is null or an empty page; down or slowed is never null.
  • Query::$domains is refused (NotSupportedException::operation(..., 'filter by domain')) by a source without Capability::DOMAINS; the other criteria a service cannot apply are applied here when the answer allows it (ORCID filters years and types itself) or left out.
  • A type the service does not have gives an empty page rather than an unfiltered one (arXiv, Open Library, INSPIRE).
  • Each work's source is the factory's name: the Merger ranks the sources by it.
  • Names through Contributor::fromName(), dates through date(), texts through text(); the identifiers through Identifier::tryOf(), so that a malformed one is left out.

Registering it

  • Plain PHP: give the registry an instance.
  • Symfony: a service implementing SourceFactoryInterface is autoconfigured with the tag omnischolar.source_factory; the bundle gives the HTTP client to its own seven factories only.
  • The Merger's default order (Merger::DEFAULT_ORDER) does not know the new source: it comes after those listed unless the application's preferences name it.
  • 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 seven providers from their checkouts), PHP 8.4, PHPUnit 11.5.57, no network: 102 tests, 505 assertions, green (Testing).

  • The core: the registry and the Collector, 31 spellings of identifiers with their checksums, the Merger's rules (identifiers in order, preferences, preprints, editions, ORCIDs lent to names), the three exports, the Symfony bundle in a booted kernel, plain PHP with no class of a framework loaded.
  • Each provider on MockHttpClient and answers recorded from its real service on 2026-10-04.

What was called live

On 2026-10-26, from the harness's image, without a mailto or a key:

  • the first script of Installation: OpenAlex's author A5108007452 and his 2024 works, merged with HAL's, exported as BibTeX;
  • one call or two of each provider, pasted in its manual: arXiv (hep-th/9711200, a search in hep-th), Crossref (10.1039/d4sc04973j), HAL (a search in shs.droit, the author remi-metivier), INSPIRE (Juan Maldacena's profile and most cited papers), OpenAlex (an author, his 2024 works), Open Library (ISBN 9781412996600), ORCID (Josiah Carberry's record and works).

Every service is open: every provider has been called live. The counts change from one day to the next (OpenAlex counted 288 works for A5108007452 on 2026-10-05, 387 on 2026-10-26).

What is not verified

  • OpenAlex with an api_key; ORCID's sandbox; the polite pools' rates (Crossref's three calls a second, as its documentation said in 2026).
  • The Laravel service provider of Laravel: written from the classes.
  • The known defect found while writing this manual: throttle not read by the OpenAlex, Crossref, HAL and ORCID factories (Configuration), fixed since with a test in each.
  • The known defect of OpenAlex's paratext (and Open Library's editions), read with PHP's (bool) so that the string 'false' was true: fixed since, both read with Config::bool(), with a test in each.

Responsibility

omnisense/omnischolar and its omnischolar/* providers are independent, unofficial clients: none is affiliated with, endorsed by or sponsored by OurResearch (OpenAlex), the CCSD (HAL), Crossref, ORCID, the Internet Archive (Open Library), arXiv or INSPIRE. Each service keeps its own terms - rates, attribution, licences of the metadata and of the full texts - named with a link in each provider's README. Whoever installs and runs these packages is responsible for complying with those terms and for the use made of what they return.

Proposer une modification docs/verified.md