ottosmops / oai-pmh
Laravel-native OAI-PMH 2.0 server: verb dispatching, resumption tokens, Blade views, pluggable repository contract.
Requires
- php: ^8.2
- illuminate/contracts: ^10.0 || ^11.0 || ^12.0
- illuminate/http: ^10.0 || ^11.0 || ^12.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0
- illuminate/view: ^10.0 || ^11.0 || ^12.0
- nesbot/carbon: ^2.0 || ^3.0
Requires (Dev)
- larastan/larastan: ^2.0 || ^3.0
- laravel/pint: ^1.0
- mockery/mockery: ^1.6
- orchestra/testbench: ^8.0 || ^9.0 || ^10.0
- phpunit/phpunit: ^10.0 || ^11.0
README
A Laravel-native OAI-PMH 2.0 server. Ships the protocol framework — verb dispatching, resumption tokens, Blade views, XML envelope — and leaves the domain-specific mapping to your repository implementations.
Implements every requirement of the
OAI-PMH 2.0 specification
including all eight error codes, deleted-record tombstones, set hierarchy,
date-granularity rules, and resumption-token pagination with
completeListSize + cursor attributes. XSD-conformance test suite
validates every response against the upstream schema.
Originally developed in collaboration with the Gosteli Archive – History of women's movements in Switzerland (University Library Bern; archive database at gosteli.anton.ch), within the Anton archive management system, and later extracted so that other archives can reuse the underlying protocol framework.
Requirements
- PHP 8.2+
- Laravel 10, 11, or 12
Installation
composer require ottosmops/oai-pmh
Publish the config (optional — sensible defaults ship in-package):
php artisan vendor:publish --tag=oai-pmh-config
Publish the Blade views (optional — only if you need to customise the XML output, e.g. add a custom XSI schema on the root element):
php artisan vendor:publish --tag=oai-pmh-views
Quickstart
1. Implement a repository
Repositories fetch records from your data source. The package handles the protocol (pagination, tokens, errors, XML) — you just return records.
<?php namespace App\Services\Oai; use Carbon\Carbon; use Illuminate\Support\Collection; use Ottosmops\OaiPmh\Contracts\OaiRepositoryContract; use Ottosmops\OaiPmh\DTOs\Identifier; use Ottosmops\OaiPmh\DTOs\ListResult; use Ottosmops\OaiPmh\DTOs\Record; class ArticleOaiRepository implements OaiRepositoryContract { public function get( int $page, int $limit, ?Carbon $from = null, ?Carbon $until = null, ?string $set = null, ): ListResult { $query = \App\Models\Article::query() ->when($from, fn ($q, $f) => $q->where('updated_at', '>=', $f)) ->when($until, fn ($q, $u) => $q->where('updated_at', '<=', $u)) ->when($set, fn ($q, $s) => $q->where('category', $s)); $total = $query->count(); $records = $query ->orderBy('updated_at') ->skip($page * $limit) ->take($limit) ->get() ->map(fn ($article) => $this->toRecord($article)); return new ListResult(records: $records, total: $total); } public function getRecordForId(string $identifier): ?Record { // Parse "oai:<namespace>:<local>" and look up by local ID $parsed = Identifier::tryParse($identifier); if ($parsed === null) { return null; } $article = \App\Models\Article::find($parsed->localIdentifier); return $article ? $this->toRecord($article) : null; } public function getEarliestDatestamp(): ?Carbon { return \App\Models\Article::query()->min('updated_at') ? Carbon::parse(\App\Models\Article::query()->min('updated_at')) : null; } private function toRecord(\App\Models\Article $article): Record { return new Record( identifier: new Identifier('journal.example.org', (string) $article->id), datestamp: $article->updated_at, sets: [$article->category], metadataXml: view('oai.oai_dc', ['article' => $article])->render(), ); } }
2. Register the repository
In config/oai-pmh.php:
return [ 'repository_name' => 'Example Journal', 'admin_email' => ['admin@journal.example.org'], 'sample_identifier' => 'oai:journal.example.org:42', 'metadataFormats' => [ 'oai_dc' => [ 'repository' => \App\Services\Oai\ArticleOaiRepository::class, 'schema' => 'http://www.openarchives.org/OAI/2.0/oai_dc.xsd', 'namespace' => 'http://www.openarchives.org/OAI/2.0/oai_dc/', ], ], 'sets' => [ ['spec' => 'research', 'name' => 'Research Articles'], ['spec' => 'opinion', 'name' => 'Opinion Pieces'], ], ];
3. Register the route
In routes/web.php:
use Ottosmops\OaiPmh\Http\Controllers\OaiController; Route::match(['get', 'post'], '/oai', OaiController::class);
Or, if you want the package to register the default route for you:
use Ottosmops\OaiPmh\Oai; Oai::routes(); // registers GET|POST /oai
4. Harvest
curl 'https://your-app.test/oai?verb=Identify' curl 'https://your-app.test/oai?verb=ListRecords&metadataPrefix=oai_dc' curl 'https://your-app.test/oai?verb=GetRecord&metadataPrefix=oai_dc&identifier=oai:journal.example.org:42'
Configuration reference
| Key | Type | Default | Purpose |
|---|---|---|---|
repository_name |
string | null |
<repositoryName> in Identify |
admin_email |
string|string[] | null |
One or more <adminEmail> |
sample_identifier |
string|Closure | null |
Example identifier shown in the oai-identifier description |
granularity |
string | YYYY-MM-DDThh:mm:ssZ |
Date granularity per §3.3 |
deletedRecord |
string | no |
no | persistent | transient |
limit |
int | 50 |
ListRecords page size |
limit_identifier |
int | 200 |
ListIdentifiers page size (headers only) |
metadataFormats |
array | [] |
Keyed by metadataPrefix; each entry has repository, schema, namespace |
sets |
array | [] |
Each entry has spec, name, optional description |
friends |
array | [] |
Optional OAI-PMH friend repository base URLs |
earliestDatestamp |
?string | null |
Fallback when no repository can supply one |
Customising views
Run vendor:publish --tag=oai-pmh-views, then edit
resources/views/vendor/oai-pmh/*.blade.php. Views receive the
following data (full shape in the verb handlers under
src/Http/Controllers/Verbs/):
| View | Data |
|---|---|
identify.blade.php |
repositoryName, adminEmails, earliestDatestamp, deletedRecord, granularity, sampleIdentifier, friends |
error.blade.php |
errors (array of [code, message]) |
get-record.blade.php |
record (Record), metadataPrefix |
list-records.blade.php |
records, metadataPrefix, token, cursor, completeListSize |
list-identifiers.blade.php |
records, token, cursor, completeListSize |
list-metadata-formats.blade.php |
formats (array of MetadataPrefix) |
list-sets.blade.php |
sets (array from config) |
All views also receive $responseDate, $requestAttributes, $baseUrl.
Deleted records
To expose a deleted record per §2.5.1 (modes persistent / transient):
Record::deleted( identifier: new Identifier('ns', 'local-123'), datestamp: Carbon::now(), sets: ['fonds'], );
The view emits <header status="deleted"> with identifier + datestamp,
and no <metadata> element.
Testing your repository
The package ships a minimal in-memory fixture (tests/Fixtures/InMemoryRepository)
you can reuse in your own test suite to wire things up before writing
your real repository.
Real-world example
Anton's archive-management system uses this package with two repositories:
Anton\Services\Oai\OaiDcRepositoryserves unqualified Dublin CoreAnton\Services\Oai\OaiEadRepositoryserves pre-exported EAD files from disk
License
MIT