The Doctrine reference data layer¶
The bundle ships a reference data layer that turns any Doctrine-ORM-mapped entity
into a fully queryable JSON:API type with no per-type code. Map an entity on a
resource and the bundle's Doctrine DataProvider and
DataPersister serve every read and write endpoint over it —
translating ?filter/?sort/?page into DQL, scoping related collections, and
committing hydrated entities through one EntityManager.
This page documents the Doctrine reference implementation: the entity-map compiler pass, the read and write pipelines, the DQL filter/sort translation, the related-collection scoping, constructor-less instantiation, and the load-state seam. The storage-agnostic SPI these classes implement is on data-layer.md; overriding or scoping the Doctrine layer is on custom-data-providers.md.
The filter and sort vocabulary these handlers execute is core's — link
filters and
sorts for the value
objects, pagination
for OffsetWindow, and
adapters for the
FilterHandlerInterface/SortHandlerInterface these Doctrine handlers implement.
Activation: the entity map¶
The Doctrine layer is active only when both of these hold:
doctrine/ormis installed (it is asuggest+require-devdependency, not a hard one — see the optional-dependency matrix on configuration.md), and- at least one resource maps an entity via
#[AsJsonApiResource(entity: …)].
You map an entity on the resource attribute:
#[AsJsonApiResource(entity: Album::class, server: ['default', 'admin'])]
final class AlbumResource extends AbstractResource
{
public static string $type = 'albums';
// …
}
— from AlbumResource,
backed by the Album
entity. The attribute and its arguments are covered on resources.md.
At container-build time DoctrineEntityMapPass collects every
#[AsJsonApiResource(entity: …)] declaration and builds a type → entity-class
map, keyed by the attribute's type override or the resource class's static
$type (the same precedence the runtime registry uses). That map is injected into
the Doctrine provider, the Doctrine persister, and (with no map argument) the
load-state predicate.
When the map is empty — Doctrine is in the vendor tree but no resource maps an
entity — the pass removes the Doctrine provider, persister, and load-state
definitions entirely, so a non-Doctrine-integrated app never holds a definition
referencing an absent EntityManagerInterface.
Build-time faults¶
These are all \LogicException at container build, never request-time errors:
| Fault | Message gist |
|---|---|
entity: names a class that does not exist |
The entity class "…" mapped by #[AsJsonApiResource] on service "…" does not exist. |
Type cannot be determined (no static $type, no type: override) |
Cannot determine the JSON:API type for the entity mapping on service "…". |
| Two resources map one type to different entities | JSON:API type "…" is mapped to two different Doctrine entities: "…" and "…". |
Source: DoctrineEntityMapPass.
Two more checks run a little later — at cache warm-up (still cache:clear / deploy,
so still the build, never a request) in the non-optional DoctrineServableWarmer, so a
declaration that would only break at request time fails the build instead:
| Fault | Message gist |
|---|---|
A sortable() / filterable column does not resolve to a real field/association (commonly a computed() field marked sortable, whose sort column defaults to the field name) |
The sort/filter "…" on JSON:API type "…" targets column "…", which is not a field or association on … |
A pivot belongsToMany (declaring pivotFields()) whose association entity cannot be discovered |
Could not auto-detect a Doctrine association entity for the pivot relation "…" … Declare it explicitly with ->through(PivotEntity::class). |
The sort check validates the resolved column, so a computed() field marked
sortable() that ALSO has a matching sorts() override supplying a real
column passes. The pivot check runs the exact same discovery the first write would — only
the timing moves. Source: DoctrineServableWarmer.
The map is keyed by type, so two (or more) resource types may map to the one
entity — a full view and a curated view of the same record, for instance. The only
mapping fault is the reverse: one type mapping to two different entities (the last
row above). With two types over one entity, the Doctrine provider resolves each type
to the same entity class and reads the same row by id — the type is supplied by the
route (primary data) or the relation's declared make() type (linkage), never derived
from the entity. See resources § One entity, two resource types
for the worked users / public-profiles pair and choosing a relation's target view.
The read pipeline¶
DoctrineDataProvider answers fetchOne and fetchCollection over the
EntityManager.
A collection fetch is one QueryBuilder pipeline:
- every supporting query extension customizes the builder first (base scopes the client cannot undo, eager-load joins);
- the shared
CriteriaApplier(data-layer.md) matches the requestedfilter[…]/sortagainst the declared vocabularies and pushes each down throughDoctrineFilterHandler/DoctrineSortHandler; - for a windowed fetch, a
COUNTruns over the filtered (un-ordered, un-windowed) query before the window applies asLIMIT/OFFSET— so items are never over-fetched and the reported total agrees with the applied scope.
A single fetch (fetchOne) runs the same extension pipeline — so a base scope
holds for GET /{type}/{id} too — and falls back to EntityManager::find() (and
its identity-map fast path) only when no extension supports the type. A row a
scope excludes comes back as null, which the handler renders as a JSON:API
404.
Only an OffsetWindow is executable: any other WindowInterface throws a
\LogicException (the Doctrine layer pushes offset/limit to SQL; it cannot
execute a cursor window). The page-based paginators core ships all resolve to an
OffsetWindow.
Source: DoctrineDataProvider.
Encoded resource ids (storage key != wire id)¶
A resource can decouple the JSON:API id a client sees (the wire id) from the
storage key its entity is actually keyed by — exactly Laravel JSON:API's custom
id encoding. Attach an encoder to the Id field with core's
Id::encodeUsing(IdEncoderInterface):
public function fields(): array
{
return [
Id::make()
->encodeUsing(new ProductIdCodec()) // storage key <-> wire id
->matchAs('prod-[0-9a-f]+'), // the route {id} requirement
Str::make('name')->required(),
];
}
interface IdEncoderInterface
{
public function encode(mixed $storageKey): string; // storage -> wire
public function decode(string $wireId): mixed; // wire -> storage; null when undecodable
}
The entity always holds the storage key. The transform runs at two boundaries:
- Core owns the entity's-own-id transform: it
encode()s the stored key on serialize (so the renderedidand every self/related link are wire ids) anddecode()s a client-supplied id on create, setting the storage key on the new entity (anulldecode is a422). A server-generated id (no client id, the default) is the storage key's own wire form and is set as-is — it is never fed todecode(), so a server-minted create is not spuriously rejected. APATCHdoes not set the id, so it needs no decode either. (A type whose id is database-generated has no id to hydrate on create at all; expose it withoutCreaterather than mint a meaningless id — see the example'sProductResource.) - The reference Doctrine layer owns the id-as-lookup-key transforms, because the
storage-agnostic
DataProvider/DataPersisterSPI passes ids as wire strings and keeps its signatures unchanged. Before the lookup the Doctrine providerdecode()s the route{id}; anulldecode short-circuits to a404(no row holds that key, so no query runs). BeforegetReference()the Doctrine persisterdecode()s each linkage id (keyed by the related type's encoder), so a relationship write whosedatacarries wire ids resolves the right managed references; a linkage id thatdecode()s tonullis a bad target and raises a404(rather than passing the raw wire string togetReference, which would build a proxy that errors on initialization — a500).
So a read round-trips wire -> (Doctrine decode) -> storageKey -> query -> entity ->
(core encode) -> wire, and a create-with-client-id round-trips wire -> (core decode)
-> entity (storageKey) -> persist -> (core encode) -> wire.
The uuid() / ulid() / numeric() / pattern() shortcuts set the route {id}
requirement and the client-id format constraint; matchAs() sets the route
requirement alone (the inner regex, no surrounding ^…$ — Symfony anchors it). The
route loader stamps that requirement on every route carrying {id}, so a malformed
id 404s at routing before any handler runs. A type with no encoder is
unchanged (wire == storage), and the in-memory provider has no encoder at all, so
encoding is a Doctrine-only concern — encoders are entirely user-supplied (no encoder
dependency is added to the bundle).
Eager-loading includes (no N+1)¶
Eager-loading of a read's ?include tree is automatic and built in — there is
no extra dependency to install (it used to require an external preloader
library; that is gone, and the batching now lives in the bundle). Includes stop
N+1ing with no per-type code. The bundle batch-loads the included relationships
Laravel-style: one query per relation per level, no fetch-joins. Each level
loads a relation for every source entity in a single WHERE id IN (…)-style
query, and the loaded targets seed the next level.
Over the example, GET /albums?include=tracks across 16 albums issues 2
include-load queries (the albums, then one batched tracks load) — not the 1 + N a
lazy render issues:
The batcher reuses core's include decision, so it loads exactly what is
rendered. This includes default includes: a resource's
getDefaultIncludedRelationships() is applied by core as a fallback — when the
request sends no ?include, the listed relationships are included (and now
batch-loaded); an explicit ?include (even an empty ?include=) overrides the default.
final class AlbumResource extends AbstractResource
{
// GET /albums with no ?include yields each album's artist in `included`
// (rendered AND batch-loaded); ?include=… or ?include= overrides it.
public function getDefaultIncludedRelationships(mixed $object): array
{
return ['artist'];
}
}
— from AlbumResource.
Batch-loading is a pure optimization: the rendered document is identical with or
without it. So a relation the batcher cannot batch silently falls back to a lazy
load — a polymorphic relation (more than one related type), a computed /
extractUsing / aliased non-association column, or a composite-key target. The
relation's storage column drives the batch (column() ?? name()), so a storedAs()
rename is honoured.
The batching runs through the same provider-agnostic
fetchRelatedCollectionBatch() seam
that windowed related collections use, so it works on the in-memory provider too (an idempotent
re-assignment that changes no rendered bytes). The
witness is IncludePreloadTest.
Eager-loaded flattened (on()) attributes¶
The same batch also eager-loads the to-one chains a resource declares as load-not-render
— so a flattened attribute
(->on('path')) does not N+1. The eager set is the dedup set of every on() attribute's
backing relation chain, declared by core's
DeclaresEagerLoadsInterface::eagerLoadRelationshipPaths() (every AbstractResource
satisfies it automatically) and executed by the bundle before the ?include walk.
Over GET /books flattening an authorName from a hidden author relation, the
authors load in one WHERE id IN (…) query for the whole page — not one
SELECT … WHERE id = ? per row (the per-row N+1 the flattened read would otherwise
introduce, since the author association is lazy). An on() chain may be multi-hop
(->on('author.country')): the bundle walks it hop by hop, so the second hop loads in
one WHERE id IN (…) too — O(depth), no per-row SELECT at any hop. It is the same
batch as ?include, with these eager-only properties:
- Never rendered. An eager relation is loaded onto the parent's column exactly as a
lazy read would have materialised it, but rendering stays gated on core's
isIncludedRelationship, which the eager set never touches. So a hiddenon()backing relation (at any nesting depth) is loaded but never appears as a relationship or inincludedunless also?include'd — eager-loading changes only the query plan, never the document. - Fail-loud at boot. An
on()chain is validated at container warm-up (cache:clear/ deploy), so a bad declaration fails the build — never a runtime 500. Every segment must be a declared to-one relation: a to-many segment at any depth throws aLogicException(on()flattens a scalar from a to-one chain — a to-many is not flattenable; use?include), and the rule bites every segment of a multi-hop chain, not just the leaf. An unknown segment (a typo) also throws (no silent no-op). A segment may behidden()or visible — both are valid, because the chain is to-one. - Bypasses the client-include safeguards. The eager set is author-declared and
trusted, so the include depth cap / allowed-paths whitelist /
cannotBeIncluded(which gate untrusted client input) do not apply to it. - Resolved hidden-inclusive. An
on()attribute's backing relation is idiomaticallyhidden()(the internal association), so the eager loader resolves it against the hidden-inclusive declared-relation set — a hidden relation is found and loaded even though it never renders.
A flattened-attribute write (PATCH setting authorName, or a multi-hop
authorCountry) mutates the loaded final related model in place; Doctrine's unit of
work auto-persists the dirty loaded entity on flush (no related-persister change). A write
over a null hop is a 422 (RELATED_ATTRIBUTE_OWNER_MISSING) — a flattened attribute
never auto-instantiates a missing related model.
Windowed includes (window_functions)¶
A plain include loads the whole related set (the fast-path above). Under the
Relationship Queries profile a request can instead window each
parent's included to-many relation to page 1 (e.g. the 5 newest comments per post). The
provider runs that as ONE bounded native ROW_NUMBER() OVER (PARTITION BY parent
ORDER BY …) query per relation — fetching only ~one page per parent plus the
real per-parent total (COUNT(*) OVER), never the parent's whole set. The result
is bounded even though the engine scans the partition, and the total
is the true cardinality — so the relationship-pagination total (and any ?withCount
overlap) is correct, not the page size.
[!IMPORTANT]
json_api.doctrine.window_functionsdefaults totrueand needs SQL window functions: MySQL ≥ 8, MariaDB ≥ 10.2, SQLite ≥ 3.25, or any PostgreSQL. On an older engine the first windowed include throws a500(logged, naming these floors). Set itfalseto use the per-parent bounded fallback — one realLIMIT/OFFSETquery per parent (no window function), rendering byte-identical documents:There is no auto-detection (no probe/cache/fallback-on-error); the switch is explicit.
Two native shapes mirror the related-collection scoping:
an inverse-FK OneToMany partitions by the related table's parent FK and hydrates
the entity inline (one statement); an owning-side / many-to-many relation joins the
join table, partitions by its parent column, and id-loads the distinct related entities
(two statements — the ORM object hydrator would otherwise dedup a member shared across
parents). The ORDER BY appends a PK tiebreak (matched in the in-memory witness) so ties
resolve identically on both providers.
A filtered windowed include (relatedQuery[<rel>][filter][…]) runs as ONE bounded
native query too: the inner scoped query carries the relatedQuery filter through the same
DQL filter executor the related-collection endpoint runs, then is wrapped with the window
functions — so a filtered windowed include is also one bounded query on on, with the
filtered per-parent total. Only a related type with a query
extension (or window_functions: false) takes the per-parent bounded fallback. See
pagination → windowed includes.
The write pipeline¶
DoctrineDataPersister is the write twin of the provider, committing through the
same EntityManager the provider reads with.
create()ispersist()+flush(). It makes no assumption that the id is pre-set: with the store-provided default (a#[ORM\GeneratedValue]column), the bundle's hydrator sets nothing on the id and Doctrine assigns it on flush — the handler then reads it back (via the serializer's id accessor) for the201body andLocation. So a plainId::make()over an auto-increment entity round-trips the database-assigned id with no persister change (see resources § Sourcing the resource id).update()relies on the target being a managed instance the hydrator mutated in place — the provider loaded it through this sameEntityManager, soupdate()is justflush(). There is nopersist/merge.delete()isremove()+flush().
The managed-update coupling is the one constraint a custom data layer must respect:
a provider that returns a detached entity from fetchOne would silently break
Doctrine updates, because there would be nothing managed to flush. Provider and
persister must share the EntityManager — the reference pair does. If you replace
one of them for a type, replace both (see
custom-data-providers.md).
Source: DoctrineDataPersister.
Relationship write query cost¶
When a write carries a data.relationships member, the persister resolves each
linkage id with EntityManager::getReference() — a lazy proxy, no query — so how
the linkage costs depends on the relation's owning side:
- A many-to-many the parent owns (the join-table side, e.g.
editors) stays O(1): the proxy is added to the owning collection and the join row inserts from its known id, never loading the related entity. Creating or replacing such a relation with 2 ids or 200 issues the same number ofSELECTs (none for the linkage). - An inverse one-to-many (the foreign key lives on the child, e.g.
commentswherecomment.article_idis the FK) costs oneSELECTper incoming id: re-pointing a child means setting its owning-side association ($comment->article = $article), and setting a field on agetReference()proxy initialises it. So a create / replace / add / remove of an inverse one-to-many is O(linkage size).
This is an accepted limitation, not a bug: re-pointing N managed
children through the ORM inherently needs them managed (the unit of work tracks each
FK change), and the only O(1) alternative — a bulk UPDATE … WHERE id IN (...) —
bypasses the unit of work and the children's lifecycle/cascade events. It only matters
for large to-many re-points; a handful of ids is negligible. If you need O(1) bulk
re-pointing, supply a custom DataPersister that issues
the bulk update. DoctrineWriteQueryBudgetTest pins both behaviours.
Owned aggregates: orphan removal on detach¶
Replacing or removing a to-many relationship (PATCH/DELETE
…/relationships/{rel}, or a relationships member in a whole-resource write)
mutates the managed owning collection in place — clear() then rebuild on a
replace, removeElement() on a remove. So Doctrine's own
orphanRemoval: true
is honoured for free: a child dropped from a collection whose association declares it
is deleted at flush, not merely detached (its FK nulled). Model a composition —
an Album owns its Tracks — with orphanRemoval: true on the inverse
OneToMany, and a relationship replace that omits a track deletes the orphaned row.
Nothing in the bundle toggles this; it follows entirely from the entity mapping (a
plain detach when the association does not declare orphanRemoval). The persister
never bypasses the collection (it issues no bulk FK UPDATE), which is exactly the
precondition the unit of work needs to schedule the orphan delete. This is the
Doctrine-native equivalent of Laravel's deleteDetached — declared on the mapping,
not a resource flag.
Constructor-less instantiation¶
On create, the persister builds a blank instance via
ClassMetadata::newInstance() — the same constructor-less mechanism the ORM uses
to hydrate entities on read. So entities with required constructor
arguments work under the generic engine without a custom persister:
public function __construct(
#[ORM\Id]
#[ORM\Column]
public string $id = '',
#[ORM\Column]
public string $title = '',
// …
) {
$this->tracks = new ArrayCollection();
}
The trade-off: because the constructor is not invoked, its
invariants/defaults do not run on create — consistent with read-hydration, where
they also don't. (Note that the Album constructor above initialises
$this->tracks; with the constructor skipped, that association collection is left
uninitialised. The persister re-initialises an uninitialised to-many collection
property to an empty ArrayCollection before applying embedded relationships, so a
whole-resource create that sets a to-many does not hit an "accessed before
initialization" \Error.) An app that needs the constructor to run overrides
instantiate() via a custom persister.
Filter translation to DQL¶
DoctrineFilterHandler executes core's filter value objects against the
QueryBuilder, pushing each predicate down as a parameter-bound andWhere. The
semantics mirror core's in-memory ArrayFilterHandler (the conformance witness),
so the same spec test passes on both providers.
| Core filter VO | DQL translation |
|---|---|
Where (=/==/===) |
= :param (DQL has one type-coercing equality) |
Where (!=/<>) |
<> :param |
Where (>/>=/</<=) |
the same operator, :param-bound |
Where (like) |
LOWER(col) LIKE :param ESCAPE '!' (%v%) — contains-match, ASCII case-insensitive |
Where (starts/ends) |
LOWER(col) LIKE :param ESCAPE '!' with v% / %v — prefix / suffix (StartsWith / EndsWith) |
Range / DateRange |
two push-down >= :min / <= :max predicates over the present bounds, on the same query |
WhereIn / WhereIdIn |
col IN (:list) |
WhereNotIn / WhereIdNotIn |
col NOT IN (:list) |
WhereNull / WhereNotNull |
col IS NULL / col IS NOT NULL (request value ignored) |
WhereHas / WhereDoesntHave |
correlated EXISTS / NOT EXISTS subquery |
WhereThrough |
dotted-traversal correlated EXISTS (the related entity narrowed by the leaf comparison) |
WhereHasMatching |
correlated EXISTS whose related entity is narrowed by an author-supplied Criteria/closure (Doctrine-only) |
WhereAll / WhereAny |
the children recombined with andX() / orX() as one composite andWhere (see below) |
Where pinned with ->fixed() |
the same DQL as the underlying Where, its compared value pinned server-side (no dedicated arm — it rides the deserialize seam) |
a custom FilterInterface |
a registered DoctrineFilterArmInterface that supports it; else core UnsupportedFilter |
A few translations carry nuance:
likeis contains, ASCII-case-insensitive. The value's%/_are escaped as literals (with!), wrapped in%…%, and both sides areLOWER()ed so the result does not depend on the platform'sLIKEcollation (PostgreSQL'sLIKEis case-sensitive; SQLite folds ASCII only). Case-folding beyond ASCII remains platform-defined. A non-string filter value matches nothing (striposrequires two strings).starts/endsmirrorlike's fold. The two prefix/suffix operators (backing theStartsWith/EndsWithconvenience filters) reuse the same wildcard-LIKEhelper — same%/_escaping, sameLOWER()both sides, same non-string → no-match — differing only in where the%wildcard wraps (v%forstarts,%vforends), so they match the in-memorystripos === 0/str_ends_withexactly.Range/DateRangeare two push-down predicates, not a subquery. A structuredRangeadds a>= :minand/or<= :maxover the present bounds onto the same primary query — one query, no join, no subquery, no N+1. A blank/absent bound is open-ended (treated as absent, byte-for-byte with the in-memorybound()), so an openfilter[<key>][max]=is a no-op rather than a400;DateRangebinds each bound as a coerced\DateTimeImmutable.- Empty-list semantics.
WhereIn/WhereIdInwith an empty list match nothing (IN ()is not valid SQL, so the handler emits1 = 0); the negated variants then match everything (a no-op). WhereAll/WhereAnyrecombine their children withandX()/orX(). A server-composed group is applied by running each child through the same dispatch a top-level filter uses — fanning the group's request value uniformly to every child — capturing the predicate it pushes down, then recombining the captured predicates withandX()(WhereAll) ororX()(WhereAny) as one compositeandWhere. So a fanning group is a multi-column search (filter[q]=foo→LOWER(name) LIKE '%foo%' OR LOWER(email) LIKE '%foo%') and a fixed-child group a canned toggle. Each child binds its own parameters on the query with the usual count-derived, collision-free placeholder names — so repeated columns and arbitrarily nested groups (A AND (B OR C)) bind distinctly. A->fixed()Whereneeds no arm of its own: its pinned value rides the existingdeserializeseam, so theWheretranslation above runs it unchanged (col = :paramwith the server-set literal bound).WhereHas/WhereThrough/WhereHasMatchingshare oneEXISTSbuilder. All three relationship filters push down through one correlatedEXISTS(NOT EXISTSforWhereDoesntHave) subquery rooted on the related entity (the first hop's target) and correlated back to the outer owner by a membershipIN-subquery on the owning association (uniform for to-one and to-many, owning-side and many-to-many). This is set-membership, not a join into the primarySELECT, so primary rows are neither duplicated nor in need ofDISTINCT, it never hydrates the relation (linkage /?include/ the relationQuery profile compose for free), and a to-one and a to-many translate identically. The three front-ends differ only in what they ask of that builder:WhereHas/WhereDoesntHaveare the degenerate pure-existence path (no leaf predicate);WhereThroughchains the path's intermediate segments as joins off the related root and compares the final segment as the leaf;WhereHasMatchingnarrows the related root with an author-supplied predicate. See the relationship-existence filtering subsection below.
The example AlbumResource declares two of them:
use haddowg\JsonApi\Resource\Filter\WhereHas;
use haddowg\JsonApi\Resource\Filter\WhereThrough;
public function filters(): array
{
return [
// filter[tracks]=1 — albums with at least one related track.
WhereHas::make('tracks'),
// filter[artist.name]=Radiohead — EXISTS-ANY over the album's artist.
WhereThrough::make('artist.name'),
];
}
— from AlbumResource.
GET /albums?filter[tracks]=1 keeps albums with at least one related track, and
it ANDs on top of the published base scope — an app-supplied query extension,
the example's PublishedAlbumsExtension;
the DoctrineExtensionTest
asserts exactly that composition.
Source: DoctrineFilterHandler.
Relationship-existence filtering: WhereHas, WhereThrough, WhereHasMatching¶
Three filters keep a row by what its relationships contain, never by a column on
the row itself. All three execute as the single correlated EXISTS subquery
described above, so they share its
properties: no fetch-join, no row multiplication, and free composition with linkage
/ ?include / the relationQuery profile.
-
WhereHas/WhereDoesntHave— pure existence. They ignore the request value and match rows whose named association has (WhereHas) or lacks (WhereDoesntHave) at least one related row. -
WhereThrough— dotted-path traversal, the constrained-existence filter.WhereThrough::make('artist.name')responds tofilter[artist.name]and keeps a row whoseartist'snamematches — an EXISTS-ANY semi-join. Every intermediate segment is a relationship (to-one or to-many, both translate identically as "there exists a … whose …") and the final segment is the compared attribute, so the path chains:filter[author.company.name]. The wire key carries the dots by default; supply an explicit key with the two-argument form (WhereThrough::make('topArtist', 'artist.name')→filter[topArtist]). The leaf comparison sharesWhere's operator vocabulary (=,!=,>,like, …) via the fluentoperator()setter (default=), and it is value-validated (thenumeric()/integer()/pattern()/constrain()shortcuts, see data-layer → validating filter values) and portable: core ships the metadata + an in-memory traversal, so the samefilter[artist.name]runs on both providers (the in-memory provider walks the object graph; the Doctrine reference renders the correlatedEXISTSrooted on the relatedArtist). The example'sAlbumResourcedeclares it —GET /albums?filter[artist.name]=Radioheadkeeps Radiohead's albums. -
WhereHasMatching— the Doctrine-only escape hatch for whatWhereThrough's single dotted comparison cannot express: a multi-column / OR / NOT predicate, or raw DQL. It lives in the bundle'shaddowg\JsonApiBundle\DataProvider\Doctrine\Filternamespace (not core), with two construction surfaces:
use Doctrine\Common\Collections\Criteria;
use haddowg\JsonApiBundle\DataProvider\Doctrine\Filter\WhereHasMatching;
public function filters(): array
{
return [
// A Doctrine Criteria applied (AND/OR/NOT) on the related root — structured and safe.
WhereHasMatching::criteria('hot', 'tracks', Criteria::create()->where(
Criteria::expr()->gt('playCount', 1000),
)),
// A raw-subquery closure parameterised by the request value — the deep hatch.
WhereHasMatching::using('named', 'tracks', static function (
QueryBuilder $sub,
string $relatedAlias,
mixed $value,
): void {
$sub->andWhere(\sprintf('%s.title LIKE :q', $relatedAlias))
->setParameter('q', '%' . $value . '%');
}),
];
}
Two boundaries follow from it being Doctrine-only: it is not portable — the same
filter[hot] key is undeclared on the in-memory provider, so a request there is a
clean 400 (the unrecognised-filter boundary, exactly like a pivot key), never a
silent non-match — and it is not value-validated (the author owns the value: it
is consumed by the closure, not compared by a declared operator, so constraints()
returns []). Reach for it only when WhereThrough cannot express the predicate.
See relationships.md for relationship-endpoint context.
Sort translation to DQL¶
DoctrineSortHandler translates only SortByField. Directives arrive most
significant first (one composite call) and append as sequential addOrderBy terms,
so the request's first sort field is the primary key, as the spec requires. The
- descending prefix is resolved by the shared CriteriaApplier and arrives as a
per-directive descending flag.
Anything computed or multi-column has no generic DQL translation. A directive whose
sort is not a SortByField is delegated to a registered
DoctrineSortArmInterface; if none supports
it, core's UnsupportedSort is raised.
Source: DoctrineSortHandler.
Custom filters and sorts (the arm seam)¶
The built-in handlers cover the core vocabulary; for a custom FilterInterface
or SortInterface of your own, register an arm — a small service the handler
consults for value objects it does not recognise, before raising
UnsupportedFilter/UnsupportedSort. No need to
replace the whole provider.
- A
DoctrineFilterArmInterfaceissupports(FilterInterface): boolplusapply(FilterInterface $filter, QueryBuilder $query, mixed $value, string $alias): void— push your predicate down withandWhere, parameter-bound, on$alias. The arm is handed the live$queryrather than a pre-allocated placeholder name, so you own placeholder uniqueness: a fixed name like:valuecollides — silently overwriting an earlier binding — when the filter runs more than once in a request or binds more than one parameter. Derive the name off the running parameter count, the same way the built-ins do, and stay clear of the reservedjsonapi_prefix (see Column safety). - A
DoctrineSortArmInterfaceissupports(SortInterface): boolplusapply(SortInterface $sort, QueryBuilder $query, bool $descending, string $alias): void— append your term withaddOrderBy(neverorderBy, which would discard earlier directives).
Both interfaces are autoconfigured — register the service and the handler picks
it up, exactly like a DoctrineExtension. The built-ins
always win; an arm is a fallthrough, so it never shadows Where/SortByField.
// A custom filter: `filter[titleContains]=…` → WHERE LOWER(resource.title) LIKE …
final class TitleContainsArm implements DoctrineFilterArmInterface
{
public function supports(FilterInterface $filter): bool
{
return $filter instanceof TitleContains;
}
public function apply(FilterInterface $filter, QueryBuilder $query, mixed $value, string $alias): void
{
// Derive a collision-free placeholder off the running parameter count — the
// SAME mechanism the built-in handler uses — so this filter can run more than
// once in a request without one binding clobbering another. The `arm_` prefix
// keeps clear of the handler's reserved `jsonapi_` parameters.
$name = 'arm_' . \count($query->getParameters());
$query
->andWhere(\sprintf('LOWER(%s.title) LIKE :%s', $alias, $name))
->setParameter($name, '%' . \mb_strtolower((string) $value) . '%');
}
}
// Order by a to-many's size: `sort=byCount` → ORDER BY SIZE(resource.<relation>).
final class OrderByRelationCountArm implements DoctrineSortArmInterface
{
public function supports(SortInterface $sort): bool
{
return $sort instanceof OrderByRelationCount;
}
public function apply(SortInterface $sort, QueryBuilder $query, bool $descending, string $alias): void
{
\assert($sort instanceof OrderByRelationCount);
// SIZE() can't sit directly in DQL ORDER BY — select it HIDDEN and order by that.
// Derive a DISTINCT alias per relation, so several count sorts in one request
// don't collide — the sort twin of how the built-in handler derives unique
// parameter names (`$sort->relation` is a validated identifier).
$variable = 'count_' . $sort->relation;
$query->addSelect(\sprintf('SIZE(%s.%s) AS HIDDEN %s', $alias, $sort->relation, $variable))
->addOrderBy($variable, $descending ? 'DESC' : 'ASC');
}
}
A custom sort arm orders the primary collection and a non-windowed related collection, but NOT a natively-windowed/paginated related to-many. That path (
GET /{type}/{id}/{rel}with the default window-function batch) can only window aSortByField— a custom sort there raises aLogicException. If a relation must be ordered by a custom sort, setjson_api.doctrine.window_functions: false(the per-parent bounded fallback) or supply a custom provider. A custom filter arm has no such limit — it composes on every path.
For a portable custom filter/sort that must also run on the in-memory provider
(the conformance witness), ship the in-memory twin too — an
ArrayFilterArmInterface
(a row predicate) or
ArraySortArmInterface
(a per-row sort key) — passed to the InMemoryDataProvider constructor (it is
hand-built, so its arms are not DI-tagged). An inherently Doctrine-specific filter (a
raw-DQL scope) ships only the Doctrine arm; it is simply not declared on an in-memory
resource.
Self-applying filters: no arm to register¶
For a one-off, dependency-free Doctrine filter, skip the separate arm service and put the
query fragment on the filter VO itself by implementing
AppliesToQueryBuilder:
final readonly class Active implements AppliesToQueryBuilder
{
public function key(): string { return 'active'; }
public function constraints(): array { return [(new Boolean())]; }
public function applyToQueryBuilder(QueryBuilder $query, mixed $value, string $alias): void
{
$query->andWhere(\sprintf('%s.archivedAt IS NULL', $alias)); // a named scope, a Criteria, raw DQL…
}
}
The handler consults it before the arm registry, so it runs with no
DoctrineFilterArmInterface registered — the self-applying twin of the arm seam, and the
query counterpart of the NativeConstraints
validation carrier. Reach for an arm instead when the application needs injected services
(a Security, a repository). Like a raw-DQL arm it is Doctrine-only — the key is undeclared
on the in-memory provider, so a request there is a clean 400. Bind the request value as a
parameter (never interpolate it), deriving a collision-free name off the running parameter
count, clear of the reserved jsonapi_ prefix.
Describe a custom filter in the OpenAPI document. A custom
FilterInterfacewith no value constraints projects an opaque, permissivefilter[…]parameter with a generic description. ImplementDescribedFilter(getDescription(): ?string) on the filter VO to give that parameter its own prose — the same hook the convenience filter library uses. A filter with a structured wire shape (a nested object, a comma-list) additionally implements core'sDescribesQueryParameterto declare its OASstyle/explodeand value schema — so it documents as adeepObjector array rather than a scalar. (Built-in andWhere/Range-derived filters already self-describe.)
Column safety¶
Filter and sort columns come from the server-side resource declaration, never
from the client (the client supplies only the declared filter key / sort field
name). Before interpolation, each column is regex-validated as a DQL field path —
^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$, dots allowed for embedded
fields — so a declaration typo fails loudly rather than reaching the DQL parser
interpolated. Values are always bound as query parameters, and every generated
placeholder/scope parameter is prefixed jsonapi_ (collision-free; reserved — a
query extension must avoid that prefix).
Related-collection scoping¶
For a related to-many endpoint (GET /{type}/{id}/{rel}), the provider's
fetchRelatedCollection scopes the related entity's query to the parent
without loading the whole collection — so ?filter/?sort/?page apply against
the related type's vocabulary, and pagination windows in SQL. There are two
branches:
- FK fast-path — a single-valued inverse association (the OneToMany case,
where the related entity carries the owning foreign key). Scoped directly by that
FK. In the example,
albums.trackstakes this path:Trackcarries the owningalbumreference, so the related-track query isWHERE resource.album = :parent. IN-subquery — any other to-many (owning-side, or many-to-many on either side). Scoped by anINsubquery rooted on the parent that keeps the related entity as the outer query root, so the shared filter/sort/count/window machinery applies identically. In the example,playlists.tracks(a many-to-many — seeTrack/Playlist) takes this path.
The RelatedCollectionTest
exercises both branches plus an unpaginated baseline, and proves the related
collection filters/sorts against the related vocabulary (a tracks default
filter even hides the explicit track from GET /albums/1/tracks). The
relationship-endpoint behaviour around these collections — paginated defaults,
linkage rendering — is on relationships.md.
The polymorphic boundary¶
A polymorphic to-many (MorphToMany, whose relatedTypes() spans more than
one type) is a deliberate hard boundary: the Doctrine provider executes one scoped
query against a single related entity class, and a polymorphic collection's members
span entity classes, so there is no single query to run. fetchRelatedCollection
throws a \LogicException for it.
A host that needs a polymorphic to-many supplies a custom provider that resolves
the members across types. The example app does exactly this:
LibraryItemsProvider
serves GET /libraries/{id}/items (a MorphToMany over tracks/albums/artists
— see LibraryResource)
by resolving each member through its per-type repository. See
custom-data-providers.md for the recipe and
relationships.md for the polymorphic rendering — link core
relations for
MorphToMany itself.
belongsToMany pivot data¶
When a belongsToMany relation declares pivot fields — as real field definitions,
->fields(Integer::make('position'), DateTime::make('addedAt')->readOnly(), …) — the
Doctrine provider reads those join-table values and exposes them: rendered as
per-member meta.pivot, sortable as a zero-config ?sort vocabulary on the
related endpoint (?sort=position auto-derives from the field), and — writable by
default — settable from the linkage meta.
A pivot filter, by contrast, is author-declared, not auto-derived. To filter on a pivot
column, add a Where (or any value filter) to the relation's withFilters() whose
target column is pivot.-prefixed:
BelongsToMany::make('orderedTracks', 'tracks')
->fields(Integer::make('position'), Integer::make('weight'))
->withFilters(
Where::make('position', 'pivot.position'), // filter[position] on the join column
Where::make('weight', 'pivot.weight'),
),
— from the example's
PlaylistResource::orderedTracks.
The pivot. column prefix routes the filter to the join alias (the cast is
auto-resolved from the backing pivot field); the wire filter[<key>] key is whatever
you name, independent of the column. The relation's withFilters()/withSorts() and
the pivot. convention are covered on
relationships → pivot data.
Pivot data only exists over an association entity. A plain #[ORM\ManyToMany]
join table holds only the two foreign keys; Doctrine cannot map a position column
on it. So a pivot relation must be backed by an association entity — Playlist
-> OneToMany -> PlaylistTrack(position, addedAt) -> ManyToOne -> Track. The provider
auto-detects that entity from your metadata (PivotAssociationResolver: the one
to-many on the parent whose target also has a ManyToOne to the far type), or
honours an explicit ->through(PlaylistTrack::class) when auto-detection is
ambiguous (two candidate entities) or finds none — in which case it throws a
\LogicException naming the relation.
The fetch is one DQL statement over the association entity, with the far entity
as the query root so the shared filter/sort/count/window machinery applies to the
related vocabulary unchanged, the pivot filters/sorts applied on the joined pivot
alias, and each declared field selected as a scalar that rides every row:
SELECT resource, pivot.position AS pivot_position, pivot.addedAt AS pivot_addedAt
FROM Track resource
INNER JOIN PlaylistTrack pivot WITH pivot.track = resource
WHERE pivot.playlist = :parent
-- [AND related-entity filters on resource] [AND pivot filters on pivot.<field>]
ORDER BY -- [pivot.<field> | resource.<field>]
-- LIMIT/OFFSET
So the rendered pivot values come from the same query that scopes, filters, sorts and paginates the page — no two-stage query and no page-shortening, so pagination is correct.
A ?sort mixing a pivot and a related field is applied in the request's directive
order across both aliases, so ?sort=position,title orders by the pivot key first
and ?sort=title,position by the related key first. Under duplicate membership
(the same far entity joined to the parent by more than one association row — a track
at two positions), the query GROUP BYs the far id: the page returns one row per
distinct member, the total is COUNT(DISTINCT), and the rendered meta.pivot is a
single representative membership row (pivot meta is one value set per member, not a
list).
Writing pivot fields (the association-entity diff)¶
A pivot field is writable unless ->readOnly(). The Doctrine persister applies a
linkage's per-member meta as an association-entity diff over the same
auto-detected entity (the PivotAssociationResolver), on both the relationship
endpoints and a whole-resource write:
- upsert each incoming member — find the existing association row for
(parent, member); if present, update its writable pivot fields frommetain place (the reorder); if absent, create a new row (parent + member + the writablemetavalues; read-only fields take their server default, e.g. a#[ORM\PrePersist]timestamp); - on
PATCH(Mode::Replace) remove the rows whose member is no longer present (full sync); onPOST(Mode::Add) leave the rest; onDELETE(Mode::Remove) remove the incoming members' rows (nometa); - a read-only pivot field supplied in
metais never written; the values are coerced through each field's own cast, and the managed association entities are persisted/removed so the flush is storage-correct.
The meta is validated against the writable pivot fields' constraints (in the
operation's create/update context) before the diff runs — a violation is a 422
pointed at the linkage meta, with no write.
Boundaries. Pivot is Doctrine-only — the in-memory provider has no association
entity, so a pivot key 400s there, no pivot meta renders, and a pivot-meta write
is ignored (the relation is a plain to-many in-memory). A belongsToMany without
fields() keeps the plain related-collection scoping above. See
relationships.md for the resource
declaration, the rendered shape and the write convention.
The load-state seam¶
DoctrineRelationshipLoadState powers a relation's lazy-linkage policy (wired into
core through Server::withRelationshipLoadState()). A to-many and a
HasOne are lazy by default; it answers, without triggering a
load, whether such a relation's linkage is already in memory, so a lazy relation
can omit its data member rather than force a lazy round-trip just to render
identifiers:
- a to-many is "loaded" only when its backing association is an
already-initialised collection — a Doctrine
PersistentCollection'sisInitialized()is consulted directly (it neither iterates nor initialises); a plain array /ArrayCollection(a fresh entity or an eager fetch) counts as loaded; - a to-one is always loaded — a lazy
ManyToOneproxy already carries its identifier, so emitting the linkage reads the foreign key off the proxy and never hits the database.
The example's albums→tracks relation relies on the lazy default — no opt-in needed:
A relation whose column() does not name a Doctrine association on the entity (or
a non-entity model the EntityManager does not manage) is treated as loaded, so
the predicate never changes behaviour for a relation it cannot reason about. In
non-Doctrine apps the seam is absent and core treats every relation as loaded.
Source: DoctrineRelationshipLoadState.
The lazy-linkage rendering convention (and the withData() eager opt-in) is core's —
link relations.
Next / see also¶
- The Provider/Persister SPI — the storage-agnostic interfaces the Doctrine classes implement, the per-type resolution, and the generic CRUD handler.
- Custom providers, query extensions & the in-memory provider
—
DoctrineExtensionInterface(the base-scope seamPublishedAlbumsExtensionuses, whoseapply()receives anExtensionContextcarrying the type, theQueryPurposeand the request-aware nullableJsonApiRequestInterface), overriding Doctrine per type, and the polymorphic escape hatch. - Relationship endpoints — how the related/relationship endpoints and polymorphic rendering build on this layer.
- Core: filters, sorts, pagination, relations, adapters.