Skip to content

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:

  1. doctrine/orm is installed (it is a suggest + require-dev dependency, not a hard one — see the optional-dependency matrix on configuration.md), and
  2. 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:

  1. every supporting query extension customizes the builder first (base scopes the client cannot undo, eager-load joins);
  2. the shared CriteriaApplier (data-layer.md) matches the requested filter[…]/sort against the declared vocabularies and pushes each down through DoctrineFilterHandler/DoctrineSortHandler;
  3. for a windowed fetch, a COUNT runs over the filtered (un-ordered, un-windowed) query before the window applies as LIMIT/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 rendered id and every self/related link are wire ids) and decode()s a client-supplied id on create, setting the storage key on the new entity (a null decode is a 422). 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 to decode(), so a server-minted create is not spuriously rejected. A PATCH does 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 without Create rather than mint a meaningless id — see the example's ProductResource.)
  • The reference Doctrine layer owns the id-as-lookup-key transforms, because the storage-agnostic DataProvider/DataPersister SPI passes ids as wire strings and keeps its signatures unchanged. Before the lookup the Doctrine provider decode()s the route {id}; a null decode short-circuits to a 404 (no row holds that key, so no query runs). Before getReference() the Doctrine persister decode()s each linkage id (keyed by the related type's encoder), so a relationship write whose data carries wire ids resolves the right managed references; a linkage id that decode()s to null is a bad target and raises a 404 (rather than passing the raw wire string to getReference, which would build a proxy that errors on initialization — a 500).

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:

GET /albums?include=tracks&page[size]=100

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 hidden on() backing relation (at any nesting depth) is loaded but never appears as a relationship or in included unless 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 a LogicException (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 be hidden() 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 idiomatically hidden() (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_functions defaults to true and needs SQL window functions: MySQL ≥ 8, MariaDB ≥ 10.2, SQLite ≥ 3.25, or any PostgreSQL. On an older engine the first windowed include throws a 500 (logged, naming these floors). Set it false to use the per-parent bounded fallback — one real LIMIT/OFFSET query per parent (no window function), rendering byte-identical documents:

# config/packages/json_api.yaml
json_api:
    doctrine:
        window_functions: false

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() is persist() + 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 the 201 body and Location. So a plain Id::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 same EntityManager, so update() is just flush(). There is no persist/merge.
  • delete() is remove() + 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 of SELECTs (none for the linkage).
  • An inverse one-to-many (the foreign key lives on the child, e.g. comments where comment.article_id is the FK) costs one SELECT per incoming id: re-pointing a child means setting its owning-side association ($comment->article = $article), and setting a field on a getReference() 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:

  • like is contains, ASCII-case-insensitive. The value's %/_ are escaped as literals (with !), wrapped in %…%, and both sides are LOWER()ed so the result does not depend on the platform's LIKE collation (PostgreSQL's LIKE is case-sensitive; SQLite folds ASCII only). Case-folding beyond ASCII remains platform-defined. A non-string filter value matches nothing (stripos requires two strings).
  • starts/ends mirror like's fold. The two prefix/suffix operators (backing the StartsWith/EndsWith convenience filters) reuse the same wildcard-LIKE helper — same %/_ escaping, same LOWER() both sides, same non-string → no-match — differing only in where the % wildcard wraps (v% for starts, %v for ends), so they match the in-memory stripos === 0 / str_ends_with exactly.
  • Range/DateRange are two push-down predicates, not a subquery. A structured Range adds a >= :min and/or <= :max over 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-memory bound()), so an open filter[<key>][max]= is a no-op rather than a 400; DateRange binds each bound as a coerced \DateTimeImmutable.
  • Empty-list semantics. WhereIn/WhereIdIn with an empty list match nothing (IN () is not valid SQL, so the handler emits 1 = 0); the negated variants then match everything (a no-op).
  • WhereAll/WhereAny recombine their children with andX()/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 with andX() (WhereAll) or orX() (WhereAny) as one composite andWhere. So a fanning group is a multi-column search (filter[q]=fooLOWER(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() Where needs no arm of its own: its pinned value rides the existing deserialize seam, so the Where translation above runs it unchanged (col = :param with the server-set literal bound).
  • WhereHas/WhereThrough/WhereHasMatching share one EXISTS builder. All three relationship filters push down through one correlated EXISTS (NOT EXISTS for WhereDoesntHave) subquery rooted on the related entity (the first hop's target) and correlated back to the outer owner by a membership IN-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 primary SELECT, so primary rows are neither duplicated nor in need of DISTINCT, 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/WhereDoesntHave are the degenerate pure-existence path (no leaf predicate); WhereThrough chains the path's intermediate segments as joins off the related root and compares the final segment as the leaf; WhereHasMatching narrows 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.

  • WhereThroughdotted-path traversal, the constrained-existence filter. WhereThrough::make('artist.name') responds to filter[artist.name] and keeps a row whose artist's name matches — 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 shares Where's operator vocabulary (=, !=, >, like, …) via the fluent operator() setter (default =), and it is value-validated (the numeric()/integer()/pattern()/constrain() shortcuts, see data-layer → validating filter values) and portable: core ships the metadata + an in-memory traversal, so the same filter[artist.name] runs on both providers (the in-memory provider walks the object graph; the Doctrine reference renders the correlated EXISTS rooted on the related Artist). The example's AlbumResource declares it — GET /albums?filter[artist.name]=Radiohead keeps Radiohead's albums.

  • WhereHasMatching — the Doctrine-only escape hatch for what WhereThrough's single dotted comparison cannot express: a multi-column / OR / NOT predicate, or raw DQL. It lives in the bundle's haddowg\JsonApiBundle\DataProvider\Doctrine\Filter namespace (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 DoctrineFilterArmInterface is supports(FilterInterface): bool plus apply(FilterInterface $filter, QueryBuilder $query, mixed $value, string $alias): void — push your predicate down with andWhere, parameter-bound, on $alias. The arm is handed the live $query rather than a pre-allocated placeholder name, so you own placeholder uniqueness: a fixed name like :value collides — 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 reserved jsonapi_ prefix (see Column safety).
  • A DoctrineSortArmInterface is supports(SortInterface): bool plus apply(SortInterface $sort, QueryBuilder $query, bool $descending, string $alias): void — append your term with addOrderBy (never orderBy, 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 a SortByField — a custom sort there raises a LogicException. If a relation must be ordered by a custom sort, set json_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 FilterInterface with no value constraints projects an opaque, permissive filter[…] parameter with a generic description. Implement DescribedFilter (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's DescribesQueryParameter to declare its OAS style/explode and value schema — so it documents as a deepObject or array rather than a scalar. (Built-in and Where/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).

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.tracks takes this path: Track carries the owning album reference, so the related-track query is WHERE resource.album = :parent.
  • IN-subquery — any other to-many (owning-side, or many-to-many on either side). Scoped by an IN subquery 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 — see Track / 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 entityPlaylist -> 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 from meta in place (the reorder); if absent, create a new row (parent + member + the writable meta values; 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); on POST (Mode::Add) leave the rest; on DELETE (Mode::Remove) remove the incoming members' rows (no meta);
  • a read-only pivot field supplied in meta is 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's isInitialized() 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 ManyToOne proxy 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:

HasMany::make('tracks', 'tracks')
    ->paginate(PagePaginator::make()->withDefaultPerPage(2)),

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