Custom data providers and persisters¶
Data access runs through a storage-agnostic service-provider interface (SPI): a
DataProviderInterface (read) and a DataPersisterInterface (write), resolved per type by a
first-supports() match in descending priority order. The Eloquent layer is
the reference implementation (auto-registered at -256 from the
model-map tiers, or hand-wired at -128); register
your own at a higher priority to
serve a type from anything — an external API, a fixed list, symfony/intl, a legacy store
(bundle ADR 0002, ported here as
ADR 0002).
The read interface¶
interface DataProviderInterface
{
public function supports(string $type): bool;
public function fetchOne(string $type, string $id): ?object;
public function fetchCollection(string $type, CollectionCriteria $criteria): CollectionResult;
// Relationship reads (defaulted in AbstractDataProvider — override only what you need):
public function fetchRelatedCollection(/* … */): CollectionResult;
public function fetchRelatedCollectionBatch(/* … */): array; // ?include batching
public function countRelated(/* … */): array; // ?withCount
public function relatedToOneMatches(/* … */): bool;
public function relatedToOneMatchesBatch(/* … */): array;
public function fetchRelationshipPivot(string $type, object $parent, RelationInterface $relation): array;
}
fetchCollection() receives a CollectionCriteria (the parsed filters, sorts, pagination
window) and returns a CollectionResult (the page of objects + total/hasMore). The
relationship seams power ?include batching, ?withCount, relationship-existence checks, and
pivots — the batcher orchestrates them provider-agnostically (ADR 0005).
AbstractDataProvider — start here¶
Extend AbstractDataProvider and you inherit neutral defaults for every relationship seam, so
a simple read-only provider implements only supports() + the two fetch methods. The
example's ChartProvider delegates to an in-memory provider over a fixed list:
use haddowg\JsonApiLaravel\DataProvider\AbstractDataProvider;
use haddowg\JsonApiLaravel\DataProvider\InMemoryDataProvider;
/** @extends AbstractDataProvider<object> */
final class ChartProvider extends AbstractDataProvider
{
private readonly InMemoryDataProvider $inner;
public function __construct()
{
$this->inner = new InMemoryDataProvider('charts', [/* fixed rows … */]);
}
public function supports(string $type): bool { return $type === 'charts'; }
public function fetchOne(string $type, string $id): ?object { return $this->inner->fetchOne($type, $id); }
public function fetchCollection(string $type, $criteria): mixed { return $this->inner->fetchCollection($type, $criteria); }
}
The example's CountryProvider is the same shape over symfony/intl Countries, declaring
its own filter[name], sort=name, and offset-window pagination — a reference-data witness
with no database.
The in-memory provider¶
InMemoryDataProvider is a full SPI implementation over an in-memory object store. It ships as
a test double and the conformance witness — every conformance test runs against both it
and the Eloquent provider, so a divergence is caught on every run. It is also the honest way
to serve a small fixed dataset. Construct it with the seed objects and (for writable stores)
id accessor closures:
$provider = new InMemoryDataProvider('genres', $seedRows, identify: $idAccessor);
JsonApi::provider($provider);
JsonApi::persister(new InMemoryDataPersister('genres', $provider->store(), fn() => new Genre()));
The write interface¶
interface DataPersisterInterface
{
public function supports(string $type): bool;
public function instantiate(string $type): object; // a blank entity for create
public function create(string $type, object $entity): object;
public function update(string $type, object $entity): object;
public function delete(string $type, object $entity): void;
public function mutateRelationship(/* … */): object; // relationship PATCH/POST/DELETE
}
instantiate() exists because the persister owns the storage mapping. mutateRelationship()
applies a relationship endpoint write. Implement TransactionalDataPersisterInterface to wrap
a write (and its deferred After* work) in a transaction — the Eloquent persister does this
over DB::transaction.
Priority and shadowing¶
Providers and persisters register with a priority; the first that supports() a type wins.
The reference Eloquent pair sits at the bottom (-256 auto-registered, -128 hand-wired),
so an application registration (default 0) shadows it for its own types while everything
else falls through to Eloquent:
JsonApi::provider(new ChartProvider()); // priority 0
JsonApi::provider($eloquent, priority: -128); // the fallback
This is how the example serves charts/countries from custom providers while every model
type falls through to Eloquent, and how a libraries polymorphic-to-many can be served by a
custom provider if you don't use the reference morph resolution.
Custom filters and sorts¶
A DataProviderInterface owns how it translates core's filter/sort vocabulary for its store:
fetchCollection() receives the parsed filters/sorts on the CollectionCriteria and applies
them however the backend requires (or rejects an unsupported one with core's
UnsupportedFilter/UnsupportedSort). The reference Eloquent provider
composes core's FilterHandlerInterface / SortHandlerInterface and extends that vocabulary
through an arm seam — register an EloquentFilterArmInterface / EloquentSortArmInterface
for a custom FilterInterface / SortInterface and the reference provider pushes it down
without you replacing the provider. See
the Eloquent arm seam.