subugoe/oai-bundle
Symfony bundle that adds an OAI-PMH 2.0 endpoint to your application, exposing repository records as Dublin Core and other metadata formats for harvesting by aggregators and discovery services. Includes configurable sets, resumption tokens, and standard error responses.
1. Install and pin. There is a single 2023 release and no dependents, so treat the version as fixed infrastructure:
composer require subugoe/oai-bundle:"^1.0"
2. Register the bundle. Flex will usually add it to config/bundles.php; if not, add Subugoe\OaiBundle\SubugoeOaiBundle::class => ['all' => true].
3. Mount the endpoint. Import the bundle routing under a dedicated prefix so the OAI endpoint never collides with app routes:
# config/routes/oai.yaml
oai:
resource: '@SubugoeOaiBundle/Resources/config/routing.xml'
prefix: /oai
The exact resource name/format can differ between releases — check vendor/subugoe/oai-bundle/Resources/config/ before copying this.
4. Configure identity. Repository name, admin e-mail and absolute base URL live in config/packages/oai.yaml. The bundle's DependencyInjection/Configuration.php is the authoritative list of keys; read it first rather than trusting the README, which may lag the code.
5. Smoke-test the five verbs before writing any integration code:
B=https://example.test/oai
for v in Identify ListMetadataFormats ListSets ListIdentifiers ListRecords; do
echo "== $v"; curl -s "$B?verb=$v" | xmllint --format - | head -30
done
Identify should return your repository name with no required arguments. If that works, routing and config are correct and everything else is data.
First real use case: expose an existing Doctrine entity — write a provider service that pages over it and maps rows to oai_dc, register it, then confirm ?verb=ListRecords&metadataPrefix=oai_dc returns headers plus <metadata>.
Provider service per collection. The bundle delegates "give me records" to a service; the service returns a page plus a cursor/resumption token. Keep the provider thin: query, map to a metadata payload, hand back. Put all XML shaping in the bundle's serializer, not your Doctrine entity, so you can add formats later without touching persistence code.
Separate query object from mapper. For each record you need (a) a header: identifier, datestamp, setSpec, optional deleted, and (b) metadata for a given prefix. Mappers keyed by metadata prefix scale better than one god-method:
final class OaiDcMapper
{
public function supports(string $prefix): bool { return $prefix === 'oai_dc'; }
public function map(Item $item): array
{
return [
'title' => [$item->getTitle()],
'creator' => $item->getAuthors(),
'date' => [$item->getIssued()->format('Y-m-d')],
'identifier' => [$item->getLandingUrl()],
];
}
}
Sets are a config concern, not a controller concern. Define setSpec/setName pairs in one place and resolve them into provider filters. Keep ListSets output and the set= query filter driven by the same array, otherwise harvesters will request sets you advertise but cannot serve.
Resumption tokens: prefer stateless. Encode (lastDatestamp, lastId, prefix, set) with an HMAC rather than storing rows; it survives cache flushes and multiple workers. If the bundle ships a DB/cache token store, check its TTL — a harvester that pauses overnight will fail on an expired token with badResumptionToken and restart from scratch.
Deletion by datestamp, not DELETE. Harvester correctness depends on seeing status="deleted" headers. Soft-delete in Doctrine, keep updated_at honest, and make sure your from/until filter runs off the deletion timestamp too.
Integration points: run your endpoint against a third-party harvester (Repository Explorer, oai-tool, or a plain scripted curl loop that follows resumption tokens to exhaustion). That loop is the only real conformance test; unit tests on your mappers will not catch protocol-level mistakes.
Identify and in every error response; under a reverse proxy or Symfony CLI server it will silently be http://localhost. Set trusted proxies (framework.trusted_proxies, HEADER_X_FORWARDED_*) or the harvester rejects the response.<error code="..."> inside an OAI envelope; an HTML Symfony exception page, or the web debug toolbar appended to the body, breaks harvesters. Disable the toolbar for the endpoint path and make sure the response content type is text/xml; charset=utf-8.Z. YYYY-MM-DDThh:mm:ssZ, no local timezone, no microseconds. Mixing granularity (day vs second) between from, until, and your stored datestamp yields badArgument or silently empty result sets.from/until/set/metadataPrefix argument validation is strict. Unknown arguments must return badArgument; missing metadataPrefix on ListRecords too. If you let the framework's parameter resolution swallow them, you get a 500 instead of a valid OAI error.identifier. Use a stable, opaque, URL-shaped identifier; ids change with re-imports and harvesters will treat every record as new.collection:subcollection — ListSets should not advertise a child whose parent can't be filtered, and set= must accept any advertised spec.bin/console debug:router and debug:container --tag=... to find which services are actually wired before assuming a config key exists.How can I help you explore Laravel packages today?