ottosmops/oai-pmh

Laravel-native OAI-PMH 2.0 server: verb dispatching, resumption tokens, Blade views, pluggable repository contract.

Maintainers

Package info

github.com/ottosmops/oai-pmh

pkg:composer/ottosmops/oai-pmh

Statistics

Installs: 14

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-04-21 08:29 UTC

This package is auto-updated.

Last update: 2026-04-21 10:27:12 UTC


README

Latest Version on Packagist Tests Coverage Total Downloads License PHP Version Require

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:

License

MIT