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
- Installation: requirements, a first script in plain PHP and its live output.
- Symfony: the bundle, its configuration tree, its services.
- Laravel: a service provider written by hand (there is no Laravel bridge).
- Concepts and models: the contract, every model, field, constant and enumeration; the identifiers.
- Usage: sources (what each does), merging (one record per work, editions), export (BibTeX, RIS, CSL-JSON).
- Configuration reference: the registry, every option of every factory.
- Errors: the exceptions, the HTTP statuses behind them, what to catch.
- Testing: testing code that uses the family, the family's own tests.
- The Docker harness: the console, bare PHP, the tests.
- Writing a provider.
- Verified, and not.
Proposer une modification docs/index.md
Installation
composer require omnisense/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/omnischolarrequiressymfony/http-client-contracts(^3.0) and nothing else: no framework, no HTTP client of its own. It replacesomniscient/omnischolar: acomposer.jsonthat still requires the old name resolves to this package.- Each provider requires
omnisense/omnischolarandsymfony/http-client(6.4, 7 or 8); arXiv needsext-dom(its answers are Atom). A factory takes anHttpClientInterface(the application's, aMockHttpClientin a test) and makes its own withHttpClient::create()when given none. - The Symfony bundle needs
symfony/config,symfony/dependency-injectionandsymfony/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, aMockHttpClientin 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, theMerger, theExport.
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\OmnischolarBundleregisters the factories on the application'shttp_client, builds the registry fromconfig/packages/omnischolar.yaml, and autowires each source by its name, theCollector, theMergerand theExport: 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
Registryonce, 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-fromortois 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, KeitaroorKeitaro 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:
- the same DOI;
- the same ISBN - for books, theses, reports and other works only: a chapter or a conference paper carries the ISBN of its book;
- the same arXiv id;
- the same HAL id;
- 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:
titleandsubtitlecome together, from the record preferred fortitle;type: the first that is notother(a published article over its preprint, by the default order);year, thendatefrom 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,sourcesare united;openAccessis 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 aScheme.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/Fixturesholds 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 indocker/harness/recorded/. - Give arXiv, INSPIRE and Open Library
throttle: 0in a test: their default throttle sleeps between two calls. - A 503 tests "down" (
UnavailableException), a 429 withRetry-Afterthe 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, orHttpClient::create()whennull).SourceFactory::userAgent($c)gives theUser-Agentto send, with themailto; passthrottleon 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 throwNotSupportedExceptionalready; 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 inTests/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
nullor an empty page; down or slowed is nevernull. Query::$domainsis refused (NotSupportedException::operation(..., 'filter by domain')) by a source withoutCapability::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
sourceis the factory's name: the Merger ranks the sources by it. - Names through
Contributor::fromName(), dates throughdate(), texts throughtext(); the identifiers throughIdentifier::tryOf(), so that a malformed one is left out.
Registering it
- Plain PHP: give the registry an instance.
- Symfony: a service implementing
SourceFactoryInterfaceis autoconfigured with the tagomnischolar.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.phpand, for the console, indocker/harness/config/sources.php.
Proposer une modification docs/providers.md
Verified, and not
What the tests prove
Run on 2026-10-26 with the harness's image against the workspace (the core and its 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
MockHttpClientand 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 authorremi-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:
throttlenot 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'seditions), read with PHP's(bool)so that the string'false'was true: fixed since, both read withConfig::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.