The Provider/Persister SPI and the generic CRUD handler¶
Every JSON:API endpoint the bundle serves — read, write, related, relationship —
goes through one storage-agnostic data layer. Two tagged SPIs, resolved per
type, and a single generic handler that drives both. You never write a
controller, and you never write a per-type operation handler: the bundle ships
one CrudOperationHandler that
dispatches the whole operation set
over the SPIs.
This page is the hub for Section D. It is the mental model you need before reading the Doctrine reference adapter (the zero-config default) or writing your own provider. The core vocabulary this page builds on — filters, sorts, pagination, the response value objects — is owned by core and linked, not re-explained.
The two SPIs¶
Data access is split in half. Reads go through a
DataProviderInterface; writes
go through a DataPersisterInterface.
Each is a Symfony service, auto-tagged by autoconfiguration, and resolved per
type by a registry. A type that registers neither has no endpoints; a type with a
provider but no persister is read-only; a type with both is full CRUD. Which
endpoints exist falls out of which capabilities you wire — see
capability composition.
DataProviderInterface — the read SPI¶
The interface has nine methods, and all nine are mandatory on the bare
interface — a PHP interface carries no bodies, so implementing
DataProviderInterface directly means stubbing every one. The first three answer
the read endpoints (supports, fetchOne, fetchCollection); the other six are
the relationship / batch / match / pivot seams.
Rather than hand-stub the six, extend the
AbstractDataProvider base class:
it leaves the three read methods abstract and supplies a neutral default for the
other six — each the value the caller treats as "this capability is absent". So a
thin provider writes only supports / fetchOne / fetchCollection. A type whose
relations are actually served overrides the relevant seam (see the per-method notes
below — in particular a filtered to-one type must override the two
relatedToOneMatches* methods). The signatures are elided here for readability;
the full interface is the
authority:
interface DataProviderInterface
{
public function supports(string $type): bool;
public function fetchOne(string $type, string $id): ?object;
public function fetchCollection(string $type, CollectionCriteria $criteria): CollectionResult;
public function fetchRelatedCollection(/* relatedType, parent, relation, criteria, request */): CollectionResult;
/** @return array<int|string, int> parentWireId => count */
public function countRelated(/* type, parents, relation, criteria, request */): array;
// --- relationship / batch / to-one-match / pivot seams ---
// (AbstractDataProvider supplies neutral defaults; documented below) ---
public function fetchRelatedCollectionBatch(/* parentType, parents, relation, criteria, request */): RelatedBatch;
public function relatedToOneMatches(/* relatedType, related, relation, criteria, request */): bool;
/** @return array<string, bool> parentWireId => target-matches */
public function relatedToOneMatchesBatch(/* parentType, parents, relation, criteria, request */): array;
/** @return array<string, array<string, mixed>> relatedId => [pivotField => wire value] */
public function fetchRelationshipPivot(string $type, object $parent, RelationInterface $relation): array;
}
supports()tells the registry which type(s) this provider answers for.fetchOne()returns the single resource ornull— the handler mapsnullto a JSON:API404.fetchCollection()receives a fully-resolvedCollectionCriteria(below) and returns aCollectionResult.fetchRelatedCollection()is the related-endpoint twin offetchCollection(): the to-many collection reachable from$parentthrough$relation, scoped to the parent then filtered/sorted/windowed per the related type's vocabulary (relationships covers the endpoint; doctrine covers the push-down). It also threadsrelation->isCountable(): a non-countable relation paginates count-free — anulltotal withwindowed: trueand ahasMoreprobe on theCollectionResult.fetchRelatedCollection()'sAbstractDataProviderdefault is an empty, unwindowedCollectionResult(no members, no pagination meta) — override it to serve a to-many related endpoint.countRelated()is the count-only batch seam for?withCountand countable relations: the cardinality of$relationfor a whole page of$parentsas awireId => countmap — ONE grouped, pushed-down count, no N+1. A pivot relation counts its association rows; a polymorphic to-many is counted in memory but throws on the Doctrine reference (relationships coverscountable()/?withCount). TheAbstractDataProviderdefault is the empty count map[](the type reports no cardinalities).fetchRelatedCollectionBatch()is the page-at-a-time twin offetchRelatedCollection(): it answers a whole page of$parentsfor one relation in a single store round-trip, returning aRelatedBatchkeyed by parent wire id. It is what the include batcher (?include, no N+1) and the windowed-include batch drive — so it serves the same automatic include batching the Doctrine layer does (doctrine → eager-loading includes). A relation a provider cannot batch returns an emptyRelatedBatch, and the caller renders that relation's includes lazily — which is also theAbstractDataProviderdefault.relatedToOneMatches()/relatedToOneMatchesBatch()answer "does this to-one's target survive the relation's?filter?" for the single-resource (relatedToOneMatches) and the batched include/primary (relatedToOneMatchesBatch) paths. When the answer isfalsethe handler nulls the to-one —data: nullonGET /{type}/{id}/{toOneRel}?filter[…], null linkage on…/relationships/{toOneRel}?filter[…], dropped fromincludedonrelatedQuery[<toOneRel>][filter](covered on relationships). A[filter]is the onlyrelatedQuerymember a to-one accepts — a[sort]/[page]on a to-one path is a400. TheAbstractDataProviderdefaults are all-match —relatedToOneMatches()returnstrue, andrelatedToOneMatchesBatch()returns the fullwireId => truemap — because a base provider that does not filter its to-ones must never null one:false(or an empty[]map, which the caller reads as no-match for every parent) would null a matching to-one. A type whose to-one relations are filtered must override both to probe the target (and, for the batch, to key the map by the serializer's wire id rather than the conventionalidmember the default reads).fetchRelationshipPivot()returns the stored pivot meta for a pivot relation's current members (relatedId => [pivotField => wire value]), so the validator folds a stored row under an incoming partial pivot update. A non-pivot relation, or any provider that cannot store pivot data (the in-memory witness), returns[]— theAbstractDataProviderdefault.
The interface is @template-covariant TEntity of object. A single-type provider
is DataProviderInterface<Album>; a multi-type provider (like the Doctrine one)
is DataProviderInterface<object>. The covariance lets the registry hold a
heterogeneous set as DataProviderInterface<object> while a single-model provider
stays precisely typed.
Tagged haddowg.json_api.data_provider
(JsonApiBundle::DATA_PROVIDER_TAG). Implement the
interface, return true from supports() for your type, and autoconfiguration
tags the service — no manual registration.
DataPersisterInterface — the write SPI¶
interface DataPersisterInterface
{
public function supports(string $type): bool;
public function instantiate(string $type): object;
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(
string $type,
object $entity,
RelationInterface $relation,
ToOneRelationship|ToManyRelationship $linkage,
Mode $mode,
bool $flush = true,
): object;
}
instantiate()hands the handler a blank instance for the hydrator to populate on create. The persister owns the storage mapping, so it owns instantiation — this is why the Doctrine persister can build constructor-less entities (ClassMetadata::newInstance()).create()/update()commit and return the entity;delete()returns nothing. Entities flow as plainobject— the handler resolves the persister by type and never needs a narrower static type, so (unlike the covariant read provider) this contract is not templated.mutateRelationship()applies a relationship-endpoint mutation. Core has already loaded the parent and validated the request shape; the persister resolves the linkage's resource-identifier ids to the actual related objects/references and mutates the association under theMode— one ofReplace,AddorRemove(the handler match block maps the three relationship operations onto these cases). TheToOneRelationship/ToManyRelationshiplinkage VOs are core's too. The same seam is reused for relationships embedded in a whole-resource write — see relationships for the endpoint, validation for the write path's validation hooks. For a pivotbelongsToMany(an association-entity-backed relation with writable pivot fields) the reference Doctrine persister reads each linkage member's pivotmetaoff theToManyRelationshiphere and applies the association-entity diff (upsert / reorder-in-place / remove) — Doctrine-only; a custom persister owns its own pivot handling (see doctrine.md).
The $flush subtlety¶
mutateRelationship() carries bool $flush = true. Relationship endpoints
commit per mutation ($flush = true). But a whole-resource write that embeds
relationships in data.relationships applies each one with $flush = false, so
the single create()/update() owns the commit — a not-yet-persisted create
target is never flushed mid-association. You only need this distinction
when writing a custom persister; the handler sets it for you.
A persister that never serves the …/relationships/{rel} write endpoints (nor
embeds relationships in whole-resource writes) can extend the
AbstractDataPersister base class:
it leaves the five write methods abstract and supplies a default
mutateRelationship() that throws a clear \LogicException. The handler calls
mutateRelationship() only for a relationship write, so a persister that never
mutates relationships simply never triggers it; one that exposes a writable
relationship overrides it.
Tagged haddowg.json_api.data_persister
(JsonApiBundle::DATA_PERSISTER_TAG).
Resolution: priority + first-supports-match¶
Both registries —
DataProviderRegistry and
DataPersisterRegistry —
resolve a type the same way: walk the tagged services in descending tag
priority order (the standard tagged_iterator semantics) and return the
first whose supports() is true.
public function forType(string $type): DataProviderInterface
{
foreach ($this->providers as $provider) {
if ($provider->supports($type)) {
return $provider;
}
}
throw new \LogicException(\sprintf('No JSON:API data provider is registered for type "%s".', $type));
}
This is the override recipe. The bundled Doctrine provider and persister register
at -128 — always last, always the fallback. An application provider tagged
at the default priority (0) sorts ahead of Doctrine and shadows it for the
types its supports() claims, with no configuration at all. The example app's
OverridingArtistProvider
is exactly this shape — a default-priority provider for artists that wins by
priority over the still-wired Doctrine fallback:
final class OverridingArtistProvider implements DataProviderInterface
{
public function __construct(private readonly DoctrineDataProvider $doctrine) {}
public function supports(string $type): bool
{
return $type === 'artists';
}
public function fetchOne(string $type, string $id): ?object
{
if ($id === self::SENTINEL_ID) {
return new Artist(id: self::SENTINEL_ID, name: self::NAME, slug: self::SENTINEL_ID, trackCount: 0);
}
return $this->doctrine->fetchOne($type, $id);
}
// …delegate fetchCollection / fetchRelatedCollection to the Doctrine provider
}
A couple of things to know:
- Priority is a tag attribute, not registry behaviour. The registry trusts the
injected iteration order; the container sorts. Raise a provider above
0only if it must beat another non-default provider, never to beat Doctrine. - No match is a
LogicException, never a404. A registered type with no data source is a wiring bug surfaced at request time, distinct from a404(a row that does not exist). Compile-time guards catch the common cases earlier — see resources and capability composition.
Custom providers walks through the full override how-to, including reusing the criteria applier so a shadow stays spec-conformant.
The collection criteria¶
A fetchCollection() (and fetchRelatedCollection()) call receives a
CollectionCriteria — everything the
provider needs to answer, pre-resolved by the handler so providers stay decoupled
from core's AbstractResource API:
final readonly class CollectionCriteria
{
public function __construct(
public QueryParameters $queryParameters,
public array $filters = [], // list<FilterInterface> — the DECLARED vocabulary
public array $sorts = [], // list<SortInterface> — the DECLARED vocabulary
public ?WindowInterface $window = null,
public array $defaultSort = [], // list<SortDirective> — applied only with no ?sort
public array $aliasOf = [], // array<string,string> — bundle-only pivot-alias routing hint
) {}
}
$filters/$sorts are the declared vocabularies (the resource's filters()
/ allSorts()) — what the requested filter[…]/sort keys are matched against,
not the request itself. $defaultSort is the resource's
defaultSort()
directives, applied by the CriteriaApplier only when the request carries no
sort (an explicit ?sort= overrides it entirely); each default is matched
against $sorts exactly as a requested sort is, so it executes through the same
sort handler. $window is the pagination fetch window to push down to the store,
or null for an unpaginated fetch; it is the polymorphic WindowInterface, and a
count-based provider narrows to the OffsetWindow it can execute. $aliasOf is a
bundle-only routing hint (no core change) mapping a filter/sort key to a
non-root query alias the CriteriaApplier applies it on — populated only on the
Doctrine pivot related-collection path (pivot keys → the pivot join alias) and
empty (so provably inert) on every other Doctrine path and every in-memory path,
where each key resolves to the query root.
CriteriaApplier — spec semantics, decided once¶
CriteriaApplier is the shared half of
a collection fetch. It matches the requested keys against the declared
vocabularies and applies each match to a provider-native query through the
provider's core filter/sort handlers,
threading the query value through (TQuery is a QueryBuilder for Doctrine, a
list in memory):
public function apply(
CollectionCriteria $criteria,
mixed $query,
FilterHandlerInterface $filterHandler,
SortHandlerInterface $sortHandler,
): mixed
Because every provider runs this same matching, the spec semantics are decided in one place and a provider only ever differs in execution:
- declared filter defaults folded into the requested map (an absent key takes its
filter's declared default; a requested key always wins) via core's
FilterDefaults; - an unknown filter key →
400(FilterParamUnrecognized); - sorting against an empty sort vocabulary →
400(SortingUnsupported); - an unknown sort field →
400(SortParamUnrecognized); - with no
sortrequested, the resource'sdefaultSort()directives apply (an explicit?sort=overrides them); each default is validated against the declared sort vocabulary just like a requested sort; - the
-prefix → descending; sorts passed as one composite call (they do not compose commutatively, so the handler owns the combination).
Pagination windowing is the provider's job, not the applier's. The applier
never slices — how a window executes (LIMIT/OFFSET vs array_slice) is the
provider's concern. This is deliberate: it keeps the in-memory provider an
attributable conformance witness for Doctrine (both run the identical applier;
only the window execution differs). A custom provider should reuse
CriteriaApplier to stay spec-conformant — see
custom providers.
While the slice is the provider's, the decision of what to fetch and how to
shape the CollectionResult — no window vs offset window, countable vs count-free,
the limit + 1 probe that powers a count-free hasMore — is centralised in core's
WindowExecutor::run().
Both bundle providers route their window/count tail through it, supplying only
store-specific closures (all/count/page/probe — QueryBuilder-backed for
Doctrine, array_slice/count for in-memory). A custom provider can do the same
rather than re-deriving the countable-vs-count-free branch logic; the cursor (keyset)
window has its own runCursor() entry point on the same executor (see
pagination → cursor).
Convenience filters¶
Where::make('name', 'name', 'like') works, but it makes you hand-wire the operator,
the value coercion and the OpenAPI schema yourself — and Where::make('age', 'age', '>=')
compares the raw string '18', so '18' >= '5' is a string comparison (a
footgun). The convenience filter library ships intent-named strategies that bake
in the operator, a typed value coercion, the matching value constraint and the
OpenAPI value schema from one declaration. They run identically on both providers
(the Doctrine handler translates each to push-down DQL, the in-memory handler is the
conformance witness):
use haddowg\JsonApi\Resource\Filter\Contains;
use haddowg\JsonApi\Resource\Filter\StartsWith;
use haddowg\JsonApi\Resource\Filter\EndsWith;
use haddowg\JsonApi\Resource\Filter\GreaterThan;
use haddowg\JsonApi\Resource\Filter\Numeric;
use haddowg\JsonApi\Resource\Filter\Boolean;
use haddowg\JsonApi\Resource\Filter\Range;
use haddowg\JsonApi\Resource\Filter\DateRange;
public function filters(): array
{
return [
// Strings — case-insensitive (ASCII) substring / prefix / suffix.
Contains::make('title'), // ?filter[title]=comput (LIKE '%comput%')
StartsWith::make('sku'), // ?filter[sku]=AB (LIKE 'AB%')
EndsWith::make('domain'), // ?filter[domain]=.io (LIKE '%.io')
// Numbers — coercion + a numeric value constraint, so '18' > '5' is numeric.
GreaterThan::make('age'), // also GreaterThanOrEqual / LessThan / LessThanOrEqual
Numeric::make('rating'), // = with numeric coercion (kills the string-compare footgun)
Boolean::make('active', 'is_active'),
// Structured ranges — min/max in ONE key, either bound optional.
Range::make('rating', 'averageRating'), // ?filter[rating][min]=9&filter[rating][max]=10
DateRange::make('published', 'published_at'), // ?filter[published][min]=1995-01-01
];
}
Need an operator the built-ins don't cover? Author a custom FilterInterface and
teach the Doctrine handler to push it down via the arm seam —
DoctrineFilterArmInterface
(the sort twin is DoctrineSortArmInterface); an autoconfigured, tagged service the
handler dispatches to before falling back to UnsupportedFilter. Registered arms
run identically over the in-memory witness via core's array-arm counterpart.
Contains/StartsWith/EndsWith and the numeric/boolean strategies are thin
Where subclasses presetting an operator (the two prefix/suffix operators starts/ends
are new in this library) — the handler's existing Where arm dispatches them. Range
and DateRange are a structured filter: their wire value is nested —
?filter[<key>][min]=…&filter[<key>][max]=… (Symfony parses it into ['min' => …, 'max' => …]),
either bound optional so an open-ended range works (min alone is a >=, max alone
a <=, both absent a no-op). A blank bound (filter[rating][max]=) is treated as
absent, so it is not a 400. On Doctrine a Range is two push-down >=/<=
predicates on one query — no subquery, no fetch, no N+1. DateRange coerces each
bound ISO-8601 → \DateTimeImmutable, so the comparison is temporal not lexical, and
a malformed bound is a clean 400 (see below). All keep the usual Where ergonomics
(an optional second arg is the backing column, plus ->describedAs() / ->example() /
relation-scoped use).
In the generated OpenAPI each filter parameter carries its strategy's
description, and a Range/DateRange renders as an OAS deepObject parameter
(style: deepObject, explode: true) with a {min, max} object value schema —
documenting the nested wire shape precisely.
Validating filter values¶
A filter is metadata: a key, a target column, an operator. By default any value a
client sends for a declared filter[<key>] flows straight to the provider — so a
mistyped value (filter[age]=banana on an integer column) reaches the data layer
and gets the provider's unhelpful default: a silent non-match in memory and on
a loosely-typed database (sqlite), or — on a strict driver such as Postgres —
a PDO type error (a 500). Either way the client gets no useful signal.
A filter can declare value constraints so the bundle rejects a bad value with a
clean 400 before it ever reaches the provider. A value-carrying filter
(Where, WhereIn, WhereNotIn, WhereIdIn, WhereIdNotIn) gains the same
fluent shortcuts the Id field's format helper uses — they append the existing
core constraint vocabulary, so no new validator wiring is needed:
use haddowg\JsonApi\Resource\Constraint\In;
use haddowg\JsonApi\Resource\Filter\Where;
use haddowg\JsonApi\Resource\Filter\WhereIdIn;
public function filters(): array
{
return [
// Each member of filter[id]=… must be an integer.
WhereIdIn::make()->integer(),
// A four-digit year.
Where::make('year')->pattern('^[0-9]{4}$'),
// A v4 UUID reference; numeric() / boolean() / uuid() are also available.
Where::make('ref')->uuid(4),
// Any core constraint VO via constrain():
Where::make('status')->constrain(new In(['active', 'archived'])),
];
}
| Builder | Appends |
|---|---|
numeric() |
Pattern('^-?[0-9]+(?:\.[0-9]+)?$') (int/decimal) |
integer() |
Pattern('^-?[0-9]+$') |
uuid(?int $v) |
UuidFormat($v) (any version, or pin one) |
boolean() |
Pattern('^\s*(?i:true\|false\|1\|0\|on\|off\|yes\|no)\s*$\|^\s*$') (case-insensitive, surrounding whitespace allowed; truthy 1/true/on/yes, falsy 0/false/off/no/'' via FILTER_VALIDATE_BOOLEAN) |
pattern($regex) |
Pattern($regex) (an ECMA-262 source) |
constrain(...$c) |
any core ConstraintInterface |
A violating value is core's
FilterValueInvalid
— a 400 with source.parameter on filter[<key>], one error per violation:
{
"errors": [
{
"status": "400",
"code": "FILTER_VALUE_INVALID",
"title": "Filter value is invalid",
"detail": "This value should be of type integer.",
"source": { "parameter": "filter[id]" }
}
]
}
It is deliberately a 400, not a 422: a bad query parameter (located by
source.parameter), not a document semantic error (a 422 located by
source.pointer — that is the validator bridge). The same 400
is rendered on both providers, because the validation runs on the value before
the filter reaches the data layer — turning the provider's unhelpful default (a
silent non-match, or a PDO 500 on a strict driver) into a deliberate client
error with source.parameter.
Mechanics, all decided in the handler before the CollectionCriteria is built:
- the value is checked through the same
ConstraintTranslatorthat gives attribute constraints teeth (the validator bridge) — so it is optional in exactly the same way: withsymfony/validatorabsent a constrained filter is inert (its constraints are metadata core never executes), matching how attribute constraints degrade. A filter with no declared constraints is unaffected; - only client-supplied values are validated, never a filter's author-set
default()(a server-trusted value theFilterDefaultsfold-in supplies); - a set value (
WhereIn/WhereIdIn/…) is split — array members, or the delimited string perdelimiter()— and each scalar member is validated, sointeger()applies to every id infilter[id]=1,banana,3; - it covers the related-collection endpoint too: a relation-scoped or
related-resource constrained filter (see relationships)
validates its value the same way on
GET /{type}/{id}/{rel}; - the convenience filters preset their constraint, so a
Numeric/Rangerejects a non-numeric value and aDateRangea non-ISO-8601 bound out of the box — forRange/DateRangeeach present, non-blank bound is validated individually (an openfilter[<key>][max]=is never validated, so it does not400). ADateRangebound is additionally checked for temporal validity: a value that is shape-valid ISO-8601 but not a real date (1997-13-99— month 13, day 99 — which a regex cannot reject) is a clean400, so a calendar-invalid bound never reaches the data layer where it would compare divergently across providers.
See core's filter docs for the full decision.
CollectionResult¶
A provider answers with a core
CollectionResult
(haddowg\JsonApi\Collection\CollectionResult) — the materialized ->items plus
the three fields that describe how it was windowed:
class CollectionResult
{
public function __construct(
public readonly iterable $items,
public readonly ?int $total = null,
public readonly bool $windowed = false,
public readonly bool $hasMore = false,
) {}
}
$totalis non-null exactly when the fetch was windowed and counted, and it is the count of the whole filtered collection before windowing — nevercount($items). The handler needs it to build a count-based page (links.last/meta.page.totalderive from it).$windoweddistinguishes a count-free windowed page (a non-countable related to-many) from a plain unpaginated fetch — both carry anulltotal, but a count-free page was sliced to a page and must rendermeta.page/linkswithouttotal/last. The handler reads it only when$totalisnull.$hasMoredrives the count-free page'snextlink without aCOUNT(the data layer typically fetches one item past the window to set it).
An unpaginated fetch leaves all three at their defaults (null/false/false) and
the handler renders a plain collection document. The class moved to core (it is the
data layer's answer on both providers); the count-free windowed/hasMore fields
power non-countable related-collection pagination — see
relationships → counting relations.
The CrudOperationHandler¶
CrudOperationHandler is the generic,
zero-per-type-handler CRUD engine. The
ServerFactory wires it via
Server::withHandler(), so Server::dispatch($operation) has a target. It
implements core's OperationHandlerInterface and dispatches on the operation type:
return match (true) {
$operation instanceof FetchResourceOperation => $this->fetch($operation),
$operation instanceof FetchRelatedOperation => $this->fetchRelated($operation),
$operation instanceof FetchRelationshipOperation => $this->fetchRelationship($operation),
$operation instanceof CreateResourceOperation => $this->create($operation),
$operation instanceof UpdateResourceOperation => $this->update($operation),
$operation instanceof DeleteResourceOperation => $this->delete($operation),
$operation instanceof UpdateRelationshipOperation => $this->mutateRelationship($operation, $operation->body(), Mode::Replace),
$operation instanceof AddToRelationshipOperation => $this->mutateRelationship($operation, $operation->body(), Mode::Add),
$operation instanceof RemoveFromRelationshipOperation => $this->mutateRelationship($operation, $operation->body(), Mode::Remove),
default => ErrorResponse::fromException(new ResourceNotFound()),
};
It is constructed with the two registries, the
TypeMetadataResolver seam (below), and
an optional ResourceValidator — null when symfony/validator is not
installed, in which case writes run unvalidated. See validation
for the validator and the silent-absence caveat.
Reads¶
fetch() resolves the provider, then:
- a single fetch (
/{type}/{id}) callsfetchOne()and mapsnullto a404, else rendersDataResponse::fromResource(); - a collection fetch resolves the resource's
filters()/allSorts()/pagination()into aCollectionCriteria, callsfetchCollection(), and rendersDataResponse::fromPage()when paginated (elseDataResponse::fromCollection()).
The effective paginator follows core's resource → server default chain. The
bundle gives every server a default paginator whose client-controlled
page[size] is capped at
json_api.pagination.max_per_page
(default 100), so a collection with no per-resource pagination() is paginated
and protected from a page-size DoS out of the box; set the cap to 0 to
install no built-in default (those collections render unpaginated), or
register a custom paginator
(e.g. cursor) per server or for all. The cap concept is owned by core — see
pagination.md → Capping the page size.
The singular-filter collapse: if the client applied a filter the resource
declares singular
(SupportsSingular), the collection collapses to a zero-to-one response — the
first match or null, never an array, never paginated. The
example app's artists declares a singular slug filter; GET
/artists?filter[slug]=radiohead renders a single resource, and a no-match renders
data: null (ReadQueryTest).
Writes¶
Writes share one shape: resolve the persister, drive core's per-type hydrator
(Server::hydratorFor()), commit, render.
create()validates the body, hydrates a freshinstantiate()instance, applies any embedded relationships, persists, and renders201with aLocationheader.update()loads the target through the read provider (404when absent), validates, hydrates onto it, applies relationships, persists, renders200.delete()loads the target (404when absent), deletes, renders204(NoContentResponse::create()).
The Location uses the resource's URI segment (uriType(), see
custom serializers/hydrators) so it matches the
route the client will GET; a bare pair with no resource falls back to the type.
WriteTest exercises all
three statuses and the 404s end to end over Doctrine.
The relationship-strip subtlety¶
Core's hydrator populates id + attributes; a data.relationships member is not
hydrated by core (a scalar linkage id would land on a typed association property).
So the handler:
- extracts the writable relationships from the body
(
extractRelationships(), reapplying core's read-only relationship gate); - strips
data.relationships(withoutRelationships()) before core hydrates; - after hydration, sets each named association through
mutateRelationship(… Mode::Replace, flush: false)so the persister resolves the linkage ids to managed references / stored objects; - lets the single
create()/update()own the commit.
A whole-resource write that embeds relationships is exercised in
RelationshipMutationTest.
Relationship endpoints¶
The related/relationship read arms (fetchRelated(), fetchRelationship()) and
the three mutation arms all share a parent-load + relation-resolve shape, then
render or mutate. Per-relation endpoint exposure, polymorphic rendering, and the
mutability/cardinality guards are owned by relationships; the
storage-correct apply rides mutateRelationship(), documented above.
Customizing the handler¶
There is no per-type handler code to write. Customization composes through:
- a higher-priority provider/persister (override the data layer for one type);
- a per-type serializer/hydrator override (override the wire shape — see custom serializers/hydrators);
- decorating this handler (the single global handler is overridable by decoration — see handler decoration).
TypeMetadataResolver — tolerating a bare pair¶
The handler resolves each type's declarative metadata (its resource, its declared
relations) through the
TypeMetadataResolver seam, not directly
off the Server. This is the capstone seam that lets the engine stay generic over
both a full AbstractResource and a bare serializer/hydrator pair — the
resource-less standalone capabilities
registered with just a serializer and hydrator and no field inventory — without
per-type branching.
public function resourceFor(Server $server, string $type): ?AbstractResource
{
try {
return $server->resourceFor($type);
} catch (NoResourceRegistered) {
return null;
}
}
resourceFor() returns null for a bare pair. So wherever the handler needs a
field inventory — filters, sorts, pagination, validation — a null resource means
those steps are simply skipped on that path: a bare-pair collection fetch passes
empty $filters/$sorts and no resource-level paginator, and writes through a
bare pair are not validated (a real gap a capability-composition user must know —
see validation). relationNamed() sources relations
resource-first then from the type-keyed
RelationsRegistry, so a resource-less type
that declared standalone relations resolves the same
way as a resource.
Where the data lives¶
The bundle ships two implementations of these SPIs, both in src/ so they are
documented, copyable examples:
| Adapter | Provider / persister | Default priority | Covered in |
|---|---|---|---|
| Doctrine ORM | DoctrineDataProvider / DoctrineDataPersister |
-128 (fallback) |
doctrine |
| In-memory | InMemoryDataProvider / InMemoryDataPersister |
(registered per type) | custom providers |
The Doctrine adapter is the zero-config default for any #[AsJsonApiResource(entity:
…)]-mapped type. The in-memory provider is a reusable worked example and a
conformance witness. Anything else — a static list, a reference dataset, a remote
API, a polymorphic to-many Doctrine cannot scope — is a custom provider over these
same SPIs.
Next / See also¶
- The Doctrine reference data layer — the zero-config default: the entity map, DQL filter/sort translation, related-collection scoping.
- Custom providers, query extensions & the in-memory provider — the override how-to and the in-memory provider construction.
- Validation — the write path's optional Symfony Validator bridge.
- Relationships — the related/relationship endpoints the handler
serves and
mutateRelationship()applies. - Core: operations, responses, filters / sorts / pagination, adapters, hydrators.