Relationship endpoints in the bundle¶
The core library owns the relation DSL — BelongsTo/HasMany/BelongsToMany/MorphTo
(HasMany is the plain to-many, BelongsToMany the pivot-backed to-many — see
core relations),
the related type as a mandatory make() argument
(BelongsTo::make('owner', 'users')), the paginate()/withData() builders, the
withoutLinks()/cannotReplace() exposure flags, and the rendering of linkage,
self/related links and ?include. Read
core relations
and core related-endpoints
for that vocabulary.
This page covers what the bundle adds: the Symfony routes for each relation,
the handler-side enforcement of per-relation exposure gates, the
storage-correct mutation seam, the queryable/paginated related-collection seam,
and how polymorphic and resource-less relations wire up. Every relation you
declare on a resource — or standalone via #[AsJsonApiRelations] —
gets the full relationship endpoint set automatically, with no extra routing.
The two read endpoints per relation¶
For any type that declares relations, the bundle's route loader
emits two read paths per relation, both parametric in {relationship} (written
{rel} for short in the path tables below):
| Endpoint | Path | Renders |
|---|---|---|
| related | GET /{type}/{id}/{rel} |
the related domain value(s) as full resources |
| relationship (linkage) | GET /{type}/{id}/relationships/{rel} |
resource-identifier objects only |
Linkage and the convention self/related links render on the parent's own
read too (GET /tracks/1), default on. A to-one with no related object renders
data: null (not a 404), and ?include flows through both the parent read and
the related endpoint. The relation DSL drives all of this — see
core relations
(withoutLinks()) and
core sparse-fieldsets-and-includes
(?include).
The bundle's job is the wiring. TrackResource
declares a to-one album and a plain to-many playlists (the pivot-bearing
variant lives on the playlists resource's orderedTracks — see
Pivot (belongsToMany) data):
BelongsTo::make('album', 'albums'),
BelongsToMany::make('playlists', 'playlists')
->cannotReplace()
->countable(),
and GET /tracks/1 renders each relationship with linkage plus the two
convention links, exactly as the
RelationshipReadTest
asserts:
"relationships": {
"album": {
"data": { "type": "albums", "id": "1" },
"links": {
"self": "https://music.example/tracks/1/relationships/album",
"related": "https://music.example/tracks/1/album"
}
}
}
Load-state-aware linkage¶
A to-many (and a HasOne) is lazy by default, rendering the
convention links without forcing a fetch; withData() opts one back to eager.
On the Doctrine path the bundle backs the lazy default with a storage-aware
load-state seam (DoctrineRelationshipLoadState, owned by
doctrine.md): an uninitialised PersistentCollection reports "not
loaded", so the rendered relationship carries links but omits the data member.
AlbumResource's
HasMany::make('tracks') keeps the lazy default, and GET /albums/1 renders
tracks with links and no data, while the explicit
GET /albums/1/relationships/tracks materialises the full identifier list.
Queryable, paginated related collections¶
GET /{type}/{id}/{rel} for a to-many is a real collection endpoint: it
honours ?filter/?sort/?page against the related type's vocabulary, not
the parent's. The bundle resolves this through a dedicated SPI seam,
DataProvider::fetchRelatedCollection() (signature in
data-layer.md) — so a custom provider can scope or replace it,
and the Doctrine reference never loads the whole collection.
Per-relation default pagination resolves along a chain: the relation's own
paginator → the related resource's paginator → the server default. The Doctrine
push-down (FK fast-path vs IN-subquery for many-to-many) is owned by
doctrine.md; the in-memory provider reads the related objects off
the parent and applies the shared CriteriaApplier plus an array window.
The RelatedCollectionTest
witnesses both branches. albums.tracks paginates two-per-page (declared on the
album's HasMany('tracks')->paginate(PagePaginator::make()->withDefaultPerPage(2))),
and ?filter/?sort scope against the tracks vocabulary — so the related
type's own default filter even hides the explicit track:
GET /albums/1/tracks → tracks 1, 3 (track 2 is explicit, filtered out), page meta
GET /albums/1/tracks?sort=-title → tracks 3, 1
GET /albums/1/tracks?filter[explicit]=true → track 2
GET /tracks/1/playlists → unpaginated (the relation declares no paginator)
A relation that declares no paginator renders its related collection without page meta — that is the unpaginated baseline.
The to-many relationship (linkage) endpoint GET /{type}/{id}/relationships/{rel}
is queryable at the same parity: ?filter/?sort/?page scope against the same
merged vocabulary and pagination is on by default (the same relation → related
resource → server-default chain). It renders the windowed page-1 linkage
(resource identifiers), and — because the spec puts a relationship's pagination in
its own links object, not the document meta — its first/prev/next(/last)
links ride the relationship document's links (the convention self stays the bare
endpoint URL). The RelationshipReadTest
witnesses it:
GET /albums/1/relationships/tracks → ids 1, 3 (track 2 is explicit, filtered out)
GET /albums/1/relationships/tracks?sort=-title → ids 3, 1
GET /albums/1/relationships/tracks?filter[explicit]=true → id 2
GET /albums/1/relationships/tracks?page[size]=1 → id 1, with a `next` link to page 2
A pivot (belongsToMany) to-many relationship endpoint is queryable too: it windows
the linkage, scopes the related + pivot vocabulary, and still renders each member's
meta.pivot (the PivotTest
witnesses GET /playlists/{id}/relationships/orderedTracks?sort=position&filter[position]=…&page[size]=…).
A polymorphic (MorphToMany) to-many relationship endpoint, whose members span
types, cannot window mixed linkage — querying it is unsupported, so a ?filter/?sort/
?page there is a clean 400 (its OpenAPI operation advertises no query parameters),
while a bare GET …/relationships/{morphMany} still renders the whole mixed linkage.
Filtering a to-one relationship¶
A to-one is not a collection, so it does not paginate or sort — but it can carry a
relation-scoped ?filter that decides whether its target is rendered at all. Give
a to-one relation a withFilters(...) over a column on its related type, and the
filter applies as a predicate on the target: when it excludes the target, the
to-one renders data: null instead of the resource. A 200 with
data: null, never a 404 — the relationship exists, the filter just matched nothing.
AlbumResource
scopes filter[name] (the related artists.name column) onto its artist to-one:
The filter reaches the to-one on all three read surfaces, each nulling the target when it doesn't match (album 1 belongs to Radiohead):
# related endpoint — full resource, or data: null
GET /albums/1/artist?filter[name]=Radiohead → data: { type: artists, id: 1, … }
GET /albums/1/artist?filter[name]=Portishead → { "data": null } (200, not 404)
# relationship (linkage) endpoint — identifier, or data: null
GET /albums/1/relationships/artist?filter[name]=Radiohead → data: { type: artists, id: 1 }
GET /albums/1/relationships/artist?filter[name]=Portishead → { "data": null }
The third surface is the Relationship Queries profile on a primary request: a
relatedQuery[<toOneRel>][filter] that excludes the target renders the linkage null
and drops the target from included:
GET /albums/1?include=artist&relatedQuery[artist][filter][name]=Radiohead
→ data.relationships.artist.data = { type: artists, id: 1 }, included = [artist 1]
GET /albums/1?include=artist&relatedQuery[artist][filter][name]=Portishead
→ data.relationships.artist.data = null, included = []
relatedQuery on a to-one is [filter] only — a [sort] or [page] on a to-one
path is a 400 (a to-one is not a list); see the profile section's
to-one bullet.
The custom-provider seams behind this are relatedToOneMatches() /
relatedToOneMatchesBatch() on
DataProviderInterface (returning
false nulls the target; the batched twin answers a whole page of parents at once so
an include does not N+1); the feature is monomorphic — a MorphTo to-one is out of
scope. The
RelationshipReadTest
and RelationshipQueriesProfileTest
witness all three surfaces on Doctrine.
Counting relations (countable() and ?withCount)¶
Mark a to-many relation countable() to opt it into counting (off by default). The
count is pushed down (never materialising the
collection) and batched across a page of parents (one grouped count per
relation, no N+1):
Counting is exposed two ways, both on the total meta key:
?withCount=rel1,rel2— a flat primary-request parameter naming relationships (like?include) — addsmeta.totalto each named relationship object when the parent is rendered (a single resource, every parent of a collection, and a related-collection member).withCountis the Countable profile's family (the bundle registers and advertises it), so a client opts in by negotiating the profile URI in theAcceptheader'sprofileparameter — without it the family is unrecognized (a400under strict validation):
Accept: application/vnd.api+json;profile="https://haddowg.github.io/json-api/profiles/countable/"
GET /albums/1?withCount=tracks → data.relationships.tracks.meta.total
GET /albums?withCount=tracks → every album's tracks.meta.total (counted in ONE grouped query)
The total is gated by countable() and being named in ?withCount (with the
profile negotiated). A ?withCount naming a relation that is not countable(),
naming a to-one, or naming an unknown relationship is a 400
(source.parameter: withCount).
- The related-collection endpoint (
GET /{type}/{id}/{rel}) is count-free by default (since G21), exactly like a primary collection: it windows without aCOUNT— nototal, nolast, a further page signalled bynext. A client opts into the total with?withCount=_self_on acountable()relation under the Countable profile (the_self_token meaning "this related collection"); an author can make it always count withwithCount()on the relation's paginator. The gate is universal — it applies to a pivot (belongsToMany) relation's endpoint exactly as to a plain one.
Behaviour change. Related collections are now count-free by default (matching primary collections). The
meta.page.total+lastlink (and the top-levelmeta.total) return only under?withCount=_self_on acountable()relation, or withwithCount()on the relation's paginator. The related-endpoint_self_count is gated by the relation'scountable()(not the related-type resource's): core's document gate is relation-aware on a related render via the relation's countability threaded throughRelatedResponse.
?withCount counts the relation's filtered set. The count reflects the SAME
filters the related-collection endpoint applies: a relation's own
filters(), the related resource's filter defaults, and any
relatedQuery[<rel>][filter] the request carries through the Relationship Queries
profile. So ?withCount=tracks of a relation whose related resource defaults
explicit=false reports the default-scoped total, and
?withCount=tracks&relatedQuery[tracks][filter][explicit]=true counts only the
matching members — exactly the totals GET /albums/{id}/tracks (with the same filter)
pages. A parent with no matching member reports 0. The common case — a relation with
no filter and no filter defaults, with no relatedQuery filter — is raw membership,
unchanged.
Behaviour change.
?withCountused to count raw membership and ignore any active relation filter. It now counts the filtered set, so a relationship object'smeta.totalcan be smaller than in prior releases — it now equals the related endpoint's filtered total. This includes filter defaults: a related resource with a default filter narrows the count even without an explicit relatedQuery filter.
A pivot (belongsToMany) relation counts distinct far members — so duplicate
membership (the same member joined to the parent by more than one association row, a
track at two positions) counts once, matching the related-collection endpoint
(which dedupes to one row per member) and the rendered linkage. The ?withCount
relationship-object total and the endpoint pagination total therefore report the same
number for the same relation/parent. A polymorphic MorphToMany is counted by the
in-memory provider (the mixed member set) but is unsupported by the Doctrine
reference provider (its members span entity classes) — supply a custom
DataProvider if you need it on Doctrine.
Relation-scoped filters and sorts¶
A relation can declare its own filters()/sorts() that augment only its
related-collection endpoint GET /{type}/{id}/{rel} — never the primary
/{relatedType} collection. Declare them with the relation builders
withFilters(...)/withSorts(...):
HasMany::make('tracks', 'tracks')
// Available ONLY on GET /playlists/{id}/tracks, not on GET /tracks:
->withFilters(Where::make('titleContains', 'title', 'like'))
->withSorts(SortByField::make('recent', 'id'));
The bundle merges this scoped vocabulary with the related resource's own when it
parses the request's ?filter/?sort on the related endpoint:
effectiveFilters = relatedResource->filters() + relation->filters()effectiveSorts = relatedResource->allSorts() + relation->sorts()
so both apply together. On a key clash (the same filter[…]/sort key declared
on both the related resource and the relation) the relation's declaration wins
— it is the more specific scope. A key in neither set still 400s
(filter[unknown] → an unrecognized-parameter error; sort=unknown →
SORTING_UNRECOGNIZED), unchanged.
The scoping is load-bearing: a relation-scoped key reaches GET /{type}/{id}/{rel}
only. On the primary /{relatedType} collection — where just the related
resource's own vocabulary applies — the relation's key is unrecognized and 400s.
This is why a contextual or pivot-derived filter/sort belongs on the relation: it
stays scoped to the one endpoint where it is meaningful.
A relation-scoped filter/sort operates on the related entity (the common case)
and works out of the box. A filter/sort that reads a pivot/join-table column is
handled separately — see Pivot (belongsToMany) data
below.
Filtering and sorting a relationship from the primary request (the Relationship Queries profile)¶
The same scoped vocabulary is reachable from the primary request — without a
second round-trip to the related endpoint — through the Relationship Queries
profile (its
specification
lives on the core docs site, the URI it is negotiated by). The bundle registers and
advertises it, so a client opts in by negotiating its URI in the Accept header's
profile media-type parameter:
Accept: application/vnd.api+json;profile="https://haddowg.github.io/json-api/profiles/relationship-queries/"
With the profile negotiated, the client addresses a relationship by its include
path and supplies a per-relationship sort / filter via the relatedQuery
family (or the rQ shorthand; both are spec-compliant because each base carries an
uppercase letter):
# order the included tracks by -duration, narrow them to one filter
GET /albums/1?include=tracks&relatedQuery[tracks][sort]=-duration&relatedQuery[tracks][filter][longerThan]=300
# the rQ shorthand is identical
GET /albums/1?include=tracks&rQ[tracks][sort]=duration
- The client addresses a top-level relation of the request's primary resource by
its name. A dotted path (
relatedQuery[tracks.album]) is legal family grammar — it parses without error — but the bundle windows only top-level relations, so a dotted path addressing a relation of an included resource resolves to no relation and is a400(address that relation at its own endpoint instead). An unknown relationship path is likewise the related-collection endpoint's same400, withsource.parameterthe canonicalrelatedQuery[<path>]. A to-one path accepts[filter]only — a[filter]passes through and may null the linkage (see Filtering a to-one relationship), while a[sort]or[page]on a to-one is the same400(a to-one is not a list). sortorders the relationship's linkagedata(and so SELECTS which members land on the included page — see below).filternarrows the set against the related-collection endpoint's vocabulary (the related resource's filters/sorts merged with the relation's own scoped ones, exactly as above) — an unknown key is the same400.pageis out for the profile: an addressed relationship always renders page 1 (the relation's default size), navigated via its own relationship-object pagination links. So a rendered to-many under the profile carriesfirst/prev/next(+lastonly when the relation iscountable()) in itslinksobject — in the spec's plain form against the relationship-linkage endpoint (/albums/1/relationships/tracks?sort=-duration&page[number]=2), never therelatedQuery[…]form (which only addresses a relationship from a parent request).- On a
[path][op]conflict betweenrelatedQueryandrQ, the canonicalrelatedQuerywins (the shorthand yields — not an error). - The family is ignored entirely when the profile is not negotiated (today's behaviour, no profile advertised) — so a relationship literally named after a reserved query family is safe.
The window only fires for a relationship whose linkage data actually renders in
this document: a relation that is ?included at the primary level
(default-includes honoured) or one that renders data unconditionally (withData()).
A lazy, links-only, not-included to-many (the default — it renders convention links
without forcing a fetch, see Load-state-aware linkage) is
not windowed: addressing it with a relatedQuery sort/filter does not apply the
criteria and does not force a fetch — it renders links-only exactly as the lazy
default intends. To window and filter such a relation, ?include it (or declare it
withData()). An unknown sort/filter key still 400s either way — addressing a
relationship the request does not render is not a way to slip a typo through. The
relationship count is unaffected: a ?withCount of a not-included, filtered to-many
still reports the filtered total (the count loads nothing and is independent of the
window).
Under ?include, the included resources reflect exactly that page-1 set — the sort
selects the page. On a collection include (GET /albums?include=tracks) each
parent's relationship is windowed to its own page 1 independently. Each to-many maps
to a distinct association, so this is unambiguous; if two relations alias one storage
column with different pagination, the rendered linkage is last-writer-wins on that
column.
Includes are batch-loaded built in — no extra dependency. The effective include
tree is loaded one query per relation per level (no 1 + N) on both providers,
driven by the same batched related-fetch seam these windowed includes use; see
doctrine → eager-loading includes.
Filtering a collection by a relationship¶
The sections above filter a relationship's own related collection. This one is
the inverse: filtering a primary collection by a condition on a relationship —
"albums whose artist is named Radiohead", "albums that have at least one track". The
core relation-existence filters (WhereHas/WhereDoesntHave) and the dotted
traversal filter (WhereThrough) declare on the resource's filters() like any
other filter and apply to its primary GET /{type} collection.
All three translate to a single correlated EXISTS (or NOT EXISTS) semi-join:
set-membership, never a fetch-join, so the primary rows are neither duplicated nor in
need of DISTINCT, the relation is never hydrated, and linkage / ?include / the
relationship-queries profile all compose for free. A to-one and a to-many translate
identically — "there exists a related row that …".
WhereThrough — dotted-path traversal (portable)¶
WhereThrough::make('artist.name') keeps a row whose artist relation's name
matches — an EXISTS-ANY semi-join across the dotted path. Every intermediate
segment is a relationship (to-one or to-many, both identical); the final segment is
the compared attribute. The path chains: WhereThrough::make('author.company.name')
hops two relations to a leaf column.
use haddowg\JsonApi\Resource\Filter\WhereThrough;
use haddowg\JsonApi\Resource\Filter\WhereHas;
public function filters(): array
{
return [
WhereHas::make('tracks'), // albums that have at least one track
WhereThrough::make('artist.name'), // filter[artist.name]=Radiohead
];
}
The wire key carries the dots by default — make('artist.name') responds to
filter[artist.name] — or pass an explicit key (make('topArtist', 'artist.name')
→ filter[topArtist]). Because both positional slots are taken, the comparison
operator is the fluent ->operator(...) setter (default =, same vocabulary as
Where: =, !=, <>, >, >=, <, <=, like), and WhereThrough carries
value constraints (->integer(), etc.) like any value filter — so a mistyped
filter[…] is a clean 400 (FILTER_VALUE_INVALID) before the provider runs.
It is portable — it works on both providers. The in-memory provider traverses
the object graph (core's ArrayFilterHandler); the Doctrine reference renders it as
a correlated EXISTS subquery rooted on the related entity. AlbumResource
declares it, and ReadQueryTest::aWhereThroughFilterTraversesTheArtistRelationByName
witnesses it:
GET /albums?filter[artist.name]=Radiohead → albums by Radiohead
GET /albums?filter[artist.name]=Portishead → albums by Portishead
GET /albums?filter[artist.name]=Nobody → data: []
WhereHasMatching — the Doctrine escape hatch (not portable)¶
When the inner predicate is more than a single column comparison — a multi-column /
OR / NOT condition, or raw DQL — reach for the bundle-only
WhereHasMatching
(haddowg\JsonApiBundle\DataProvider\Doctrine\Filter\). It is a single relationship
hop whose related root you narrow yourself, with two surfaces:
use haddowg\JsonApiBundle\DataProvider\Doctrine\Filter\WhereHasMatching;
use Doctrine\Common\Collections\Criteria;
public function filters(): array
{
return [
// structured: apply a Criteria (AND/OR/NOT over the related entity) on the
// related root, responding to filter[hitTracks]
WhereHasMatching::criteria(
'hitTracks',
'tracks',
Criteria::create()->where(Criteria::expr()->gt('plays', 100)),
),
// raw hatch: a closure given the related-rooted subquery, the related
// alias, and the request value — you add predicates and bind parameters
WhereHasMatching::using('namedLike', 'tracks', static function ($sub, $alias, $value): void {
$sub->andWhere("$alias.title LIKE :q")->setParameter('q', "%$value%");
}),
];
}
Two boundaries set it apart from WhereThrough:
- Doctrine-only. It lives in the Doctrine namespace and is recognised only by the
Doctrine handler. On the in-memory provider the same
filter[<key>]key is undeclared, so the request is a clean400(the unrecognised-filter boundary) — never a silent non-match. - Not value-validated. The author owns the request value (the closure consumes it,
no declared operator compares it), so
constraints()returns[]— there is nothing for the validator bridge to check.
Both WhereThrough and WhereHasMatching share the one EXISTS builder in
DoctrineFilterHandler; see
doctrine.md for the DQL detail.
Pivot (belongsToMany) data¶
A belongsToMany relation can expose pivot (join-table) data: per-member values
that live on the join, like a position ordering or an addedAt timestamp. Declare
the pivot fields on the relation with fields() — as real field definitions, the
same field DSL you use for attributes (Integer, Str, DateTime, …) with their
constraints, casts and read-only / context behaviour:
BelongsToMany::make('tracks', 'tracks')
->fields(
Integer::make('position')->required()->min(1),
DateTime::make('addedAt')->readOnly(), // server-owned, never written from meta
Str::make('note')->maxLength(140),
);
The fields() declaration drives pivot render, write / validate and sort.
It renders each member's pivot values as meta.pivot on the related endpoint
(GET /playlists/1/tracks), the relationship-linkage endpoint (GET
/playlists/1/relationships/tracks), and in a primary-resource document's
relationships block — wherever that relation's linkage data renders, i.e. when it is
?included (GET /playlists/1?include=tracks) or renders data by default
(withData()). It also makes ?sort=position recognised on that related endpoint
(routed to the pivot column) — sorting is zero-config:
"data": [
{
"type": "tracks", "id": "7",
"attributes": { "title": "Intro" },
"meta": { "pivot": { "position": 1, "addedAt": "2024-01-01T00:00:00+00:00" } }
}
]
On a primary document, pivot rides the linkage where its data renders. In a primary-resource document (
GET /playlists/1) a pivot relation's linkage carriesmeta.pivoton each identifier only where its linkage data renders — when the relation is?included, or renders data by default (withData()). A lazy, not-included relation renders links-only and carries no pivot, so opt it in withwithData()(or request it with?include) to surface pivot without an?include. Pivot is Doctrine-only (it needs an association entity): the in-memory provider never renders it, on any endpoint or document.
Filtering by a pivot column¶
Pivot filters are author-declared — distinct from sorts, which auto-derive. To
expose a ?filter[…] over a pivot column, declare it on the relation's
withFilters() as a normal core filter whose column is pivot.-prefixed. The
pivot. prefix marks the filter as targeting the join: the bundle strips the prefix
to the real pivot column (position) and routes the filter to the pivot alias. The
filter key is independent of the column, so a pivot filter can be named anything:
BelongsToMany::make('tracks', 'tracks')
->fields(
Integer::make('position')->required()->min(1),
DateTime::make('addedAt')->readOnly(),
)
->withFilters(
Where::make('position', 'pivot.position'), // filter[position]=2
Where::make('positionGte', 'pivot.position', '>='), // an operator
WhereIn::make('positionIn', 'pivot.position'), // filter[positionIn]=1,3
Where::make('addedAfter', 'pivot.addedAt', '>'), // a typed-date column
);
Any core scalar-column filter works on a pivot column: Where and its operators,
WhereIn/WhereNotIn, WhereNull/WhereNotNull. (WhereHas/WhereDoesntHave are
relationship-existence filters, not a scalar pivot column, so they are not
pivot-applicable.) The value cast auto-resolves from the declared pivot field
backing the stripped column — Integer coerces to int, DateTime to an ISO-8601
comparison — so a typed pivot column filters correctly with no extra wiring; an
explicit ->deserializeUsing() on the filter still wins. A filter with no pivot.
prefix targets the related entity, exactly like any relation-scoped filter.
A pivot field declared hidden() is filterable and sortable but never rendered:
hidden() gates rendering only, never query. The field stays out of each member's
meta.pivot, yet a pivot.-prefixed filter (and ?sort=) over its column still
works — the filter reads the column on the join directly, not the rendered scalar.
Use it for a join column you want to query by but not expose.
Filter / sort asymmetry. A pivot sort auto-derives from
fields()—?sort=positionworks with no further declaration. A pivot filter must be declared explicitly viawithFilters()(a sort is a single well-defined ordering; a filter spans operators, sets and null checks the author must choose).Migration (breaking change). Declaring a pivot field no longer auto-exposes a zero-config
filter[<field>]equality. An app that relied on it must add the explicit declaration:->withFilters(Where::make('position', 'pivot.position')). Pivot sorts are unaffected (still zero-config).
The Doctrine fact this rests on. A plain #[ORM\ManyToMany] join table holds
only the two foreign keys — Doctrine cannot map a position/addedAt column on it.
To HAVE pivot columns you must model the join as an association entity:
#[ORM\Entity]
class PlaylistTrack
{
#[ORM\ManyToOne(targetEntity: Playlist::class, inversedBy: 'playlistTracks')]
public ?Playlist $playlist = null;
#[ORM\ManyToOne(targetEntity: Track::class)]
public ?Track $track = null;
#[ORM\Column(type: 'integer')]
public int $position = 0;
#[ORM\Column(type: 'datetime_immutable')]
public ?\DateTimeImmutable $addedAt = null;
}
with the parent owning a OneToMany to it (Playlist -> playlistTracks ->
PlaylistTrack) and the entity a ManyToOne to the far type (PlaylistTrack -> track
-> Track). The Doctrine adapter auto-detects that association entity from your
metadata (it finds the one to-many on the parent whose target also has a ManyToOne
to the far type) and reads pivot render + filter + sort in one DQL query over it
— correctly scoped, filtered, sorted and paginated, no extra round-trip. If
auto-detection is ambiguous (two candidate association entities) or finds none, it
throws a clear error pointing you at the override:
BelongsToMany::make('tracks', 'tracks')
->fields(Integer::make('position'))
->through(PlaylistTrack::class); // name the association entity explicitly
Writing pivot data¶
A pivot field is writable by default — settable from the linkage's
resource-identifier meta (JSON:API allows a resource identifier to carry meta).
Opt a server-owned column out with ->readOnly() (or ->readOnlyOnCreate() /
->readOnlyOnUpdate()); a read-only pivot field is never written from meta and
takes its server value. The convention is the same on the relationship endpoints and
inline in a whole-resource write:
// POST /playlists/1/relationships/tracks — add a member with its pivot data
{ "data": [ { "type": "tracks", "id": "7", "meta": { "pivot": { "position": 3 } } } ] }
// PATCH /playlists/1/relationships/tracks — full replacement = REORDER + sync
{ "data": [
{ "type": "tracks", "id": "9", "meta": { "pivot": { "position": 1 } } },
{ "type": "tracks", "id": "7", "meta": { "pivot": { "position": 2 } } }
] }
// PATCH /playlists — the SAME linkage meta inline in a whole-resource write
{ "data": { "type": "playlists", "id": "1", "relationships": {
"tracks": { "data": [ { "type": "tracks", "id": "7", "meta": { "pivot": { "position": 3 } } } ] }
} } }
The Doctrine adapter applies an association-entity diff: for each incoming member
it upserts the join row — updating an existing row's writable pivot fields in place
(the reorder), or creating a new row (the writable fields from meta, the read-only
fields taking their server default). A PATCH (full replacement) also removes the
rows whose member is no longer in the set; a POST adds/upserts the incoming and
leaves the rest; a DELETE removes the incoming members' rows (a remove carries no
pivot). Each member's meta is validated against the writable pivot fields'
constraints, with a violation rendered as a 422 pointed at the linkage meta
(/data/relationships/<rel>/data/<n>/meta/pivot/<field>, or /data/<n>/meta/pivot/<field> on the
relationship endpoint). Because an add/replace may create a new association row for
any incoming member — even on a PATCH — the meta is validated in the new-row
(create) context, matching the persister (a new row is written in create context); a
reorder of an existing row supplies the value, so this never wrongly rejects it. A
read-only pivot field supplied in meta is ignored (it is not in the writable
set, so it never raises and is never written — exactly how a read-only attribute is
handled). A required writable pivot field absent when a new row is created is a
422 (at the linkage meta, before persist — never a database NOT-NULL 500), on the
relationship-endpoint POST/PATCH and the whole-resource POST/PATCH alike.
Boundaries.
- Doctrine-only. Pivot data requires an association entity to query and write. The
in-memory provider does not support it: a pivot
?filter/?sortkey is unrecognised there (400), nometa.pivotrenders, and a pivot-meta write is ignored (the relation is a plain to-many in-memory — there is no join row to hold it). AbelongsToManywithoutfields()(and anyHasMany) keeps the plain related-collection behaviour on both providers. - Scoped to the pivot relation's related endpoint. A pivot key is unrecognised on
the primary
/{relatedType}collection (400), exactly like a relation-scoped key. - One pivot row per member.
meta.pivotis a single per-member value set, not a list. If the same member appears more than once (duplicate membership — a track at two positions), the collection returns one row per distinct member: the total is the distinct member count, no member splits across pages, and the rendered pivot values are a representative membership row. To render every membership, model the membership as its own resource instead.
See doctrine.md for the query, resolver and association-entity-diff detail.
Parent-aware identifier meta¶
meta.pivot is one automatic form of resource-identifier meta — the pivot columns
of a belongsToMany. For any other per-link metadata, declare identifierMeta() on
the relation: a resolver receiving (parent, related, request) whose result is merged
onto each linkage identifier the relation renders — on a to-many member, a to-one's
identifier, and the /{type}/{id}/relationships/{rel} endpoint. It is parent-aware
(the pivot's far member sees only itself; this sees the owning model too) and so can
describe the link rather than the resource:
HasMany::make('members', 'users')
->identifierMeta(fn(Team $team, User $member, $request): array => [
'role' => $team->roleOf($member),
]),
GET /teams/1/relationships/members
→ data: [ { "type": "users", "id": "7", "meta": { "role": "captain" } }, … ]
The resolver runs identically on the in-memory and Doctrine providers — it reads the
related objects the provider already loaded for the linkage, so it needs no data-layer
support. It composes with meta.pivot (both ride the identifier's meta, the resolver
winning on a key collision) and is linkage-only — the resource expanded into included
is untouched. The hook itself is core; see
core relations.md
for the full reference.
Relationship mutation¶
The bundle serves PATCH/POST/DELETE …/relationships/{rel} over the
CrudOperationHandler. Core owns the
mutation model — request-shape validation (cardinality → 400, mutability flags →
403) and the typed exceptions
(core relationship-mutation).
The bundle owns the storage-correct apply: it loads the parent, resolves the
named relation, guards mutability, parses the linkage with core's body parser,
then calls the persister's DataPersister::mutateRelationship() seam (the
six-arg signature lives in data-layer.md).
Before the persister runs, the linkage's resource type is checked against the
relation's declared related type(s). A { "type": T, "id": X } whose T is not an
accepted inverse type — a BelongsTo::make('author', 'authors') sent a
{ "type": "comments" } linkage — is a 409 Conflict
(RESOURCE_TYPE_UNACCEPTABLE), the linkage twin of the resource-type conflict core
already returns for a POST /{type} whose data.type does not match the endpoint.
A polymorphic relation (MorphTo::make('pinned', ['notes', 'images'])) accepts any
of its declared types. The same check
covers a relationship embedded in a whole-resource write (the error then points at
/data/relationships/{rel}/data/type). This is distinct from the cardinality
guard (a to-one sent an array → 400), which is about the linkage shape, not the
resource type of its identifiers.
The persister resolves the linkage's identifier ids to the actual related
objects/references — the Doctrine reference to a managed reference + FK write, the
in-memory provider to the stored object via its $relatedResolver. An empty
linkage clears the relationship. The same seam is reused for relationships
embedded in whole-resource writes (a data.relationships member on a
POST/PATCH /{type} — applied with flush: false so the single
create()/update() owns the commit).
The RelationshipMutationTest
exercises the full matrix over Doctrine, re-reading after clearing the identity
map so the assertion proves the change reached the database:
PATCH /tracks/1/relationships/album {"data":{"type":"albums","id":"2"}} → 200, replaced
PATCH /tracks/1/relationships/album {"data":null} → 200, cleared
POST /tracks/3/relationships/playlists {"data":[{…}]} → 200, added (idempotent)
DELETE /tracks/1/relationships/playlists {"data":[{…}]} → 200, removed
A whole-resource write threads the same path — a favorites create carrying a
to-one user in data.relationships writes the FK, and a tracks PATCH
carrying album replaces the association.
Note: a
data.relationshipsmember on a create/update is not hydrated by core onto a typed association property — the bundle strips it before core hydrates id+attributes, then applies each relationship throughmutateRelationship(... Mode::Replace, flush: false). See data-layer.md.
Request-aware mutation guards¶
The cannotReplace()/cannotAdd()/cannotRemove() flags also accept a closure,
making the 403 a per-caller decision rather than a blanket lock:
HasMany::make('medals', 'medals')
->cannotReplace(static fn(mixed $model, JsonApiRequestInterface $request): bool
=> $request->getHeaderLine('X-Role') !== 'admin')
The closure returns true when the restriction applies (cannot replace), so a
non-admin PATCH …/relationships/medals is FullReplacementProhibited (403) while an
admin's succeeds; cannotAdd/cannotRemove gate POST/DELETE the same way. The
handler resolves each gate against the inbound request and the loaded parent
(allows*For($request, $parent)), and the same request-aware read-only gate applies
to a relation embedded in a whole-resource write (a readOnly(fn) relation is skipped
only for the callers it is read-only for). This is the relationship arm of the
per-caller predicate family.
The replacement gate on embedded whole-resource writes¶
A relationship embedded in a data.relationships member of a whole-resource write is
applied as a full replacement of that association (an embedded write is always the
complete set, never an incremental add or remove). So on a whole-resource PATCH
the handler enforces cannotReplace() on each embedded relation exactly as it does at
the dedicated endpoint — a non-admin PATCH /badges/1 whose body embeds the gated
medals relation is FullReplacementProhibited (403), not a silent replacement. The
gate runs before the persister applies, so nothing is mutated.
The gate is skipped on a POST (create): a create sets the relationship's initial
state — there is nothing to replace — and gating it would make a cannotReplace relation
impossible to ever set, since such a relation has no relationship endpoint either. So a
guest may POST /badges with the gated medals embedded; only a replacement of an
existing relationship is gated.
cannotAdd()/cannotRemove() have no embedded analogue: an embedded write is always
a full set, never an incremental POST-add or DELETE-remove, so those two gates apply
only at the …/relationships/{rel} endpoint.
Per-relation endpoint exposure¶
A relation can suppress individual endpoints. The exposure flags themselves are
core (RelationInterface), but the bundle enforces them handler-side —
the relationship routes stay parametric (one route per shape, emitted once per
type), so suppression cannot live in routing. The handler maps each flag to a
JSON:API error, and core already omits the convention link to a suppressed
endpoint, so a rendered self/related link never points at a 404:
| Flag (core, on the relation) | Request affected | Bundle result |
|---|---|---|
withoutRelatedEndpoint() |
GET /{type}/{id}/{rel} |
404 RELATIONSHIP_NOT_EXISTS |
withoutRelationshipEndpoint() |
GET/mutate …/relationships/{rel} |
404 RELATIONSHIP_NOT_EXISTS |
cannotAdd() |
POST …/relationships/{rel} |
403 ADDITION_PROHIBITED |
cannotReplace() |
PATCH …/relationships/{rel} |
403 FULL_REPLACEMENT_PROHIBITED |
cannotRemove() |
DELETE / to-one clear |
403 REMOVAL_PROHIBITED |
The enforcement is in
CrudOperationHandler::guardMutability().
TrackResource's
playlists declares cannotReplace(), so a full PATCH replacement of that
to-many is a 403 and the existing set is untouched — witnessed by
RelationshipMutationTest::patchingACannotReplaceToManyIsForbidden. A cardinality
mismatch (a POST/DELETE against a to-one) is a separate 400
RELATIONSHIP_TYPE_INAPPROPRIATE; an unknown relation or missing parent is a
404.
Controlling what can be included¶
?include is a compound-document amplifier — a deeply nested path or a
default-include cascade can walk the relationship graph without bound, and a
default-include pointing back at its own type (or a mutual pair) loops the renderer
forever. Three composing include safeguards bound it. All three
live in core (an opt-in IncludeControlsInterface the transformer reads via
instanceof, plus a relation-level wither); the bundle supplies the opinionated depth
default and makes the built-in include batcher respect them.
| Safeguard | Where declared | Effect |
|---|---|---|
| A — per-relation opt-out | cannotBeIncluded() on the relation |
A ?include naming it is a 400 INCLUSION_NOT_ALLOWED at any path, and it is excluded from the default-include cascade. |
| B — max include depth | json_api.max_include_depth (default 3), overridable per resource via IncludeControlsInterface::maxIncludeDepth() |
A ?include deeper than the effective cap is a 400 INCLUSION_DEPTH_EXCEEDED; a default cascade stops at the cap (so a mutual default-include cycle terminates). |
| C — allowed include paths | IncludeControlsInterface::getAllowedIncludePaths() on the root resource |
A list of the full dotted paths a client may request when this resource is the request's root (a listed deep path implies its ancestors); any requested path neither listed nor an ancestor of one is a 400 INCLUSION_NOT_ALLOWED. null is unrestricted; [] permits no includes. |
They compose: a requested path is permitted only if every hop's relation is includable (A), it is within the effective max depth (B), and — when the root sets a whitelist — it is a member of that list (C).
A relation marks itself non-includable like any other flag:
// A back-reference whose only purpose is reverse navigation, not compounding:
BelongsTo::make('parent', 'categories')->cannotBeIncluded();
Capability A is all-or-nothing for a relation at its own level — it cannot say
"comments is includable on posts directly (GET /posts?include=comments) but NOT
when reached from a parent (GET /users?include=posts.comments)". Capability C closes
exactly that gap: it is evaluated once against the request's root resource and
governs the whole nested tree, so it can forbid a nested path even where the relation is
includable from its own root:
final class UserResource extends AbstractResource
{
// Reading a user may compound their posts and each post's author, but never a
// post's comments — even though `comments` is freely includable on `posts`.
public function getAllowedIncludePaths(): ?array
{
return ['posts.author'];
}
}
A listed deep path implies its ancestors, so ['posts.author'] permits both
posts and posts.author without enumerating every prefix; only the unlisted
sibling posts.comments is rejected.
max_include_depth is the one config-driven safeguard — see
configuration → max_include_depth for the cap,
the per-resource override, and how it terminates a default-include cycle. The built-in
include batcher (no extra dependency; see
doctrine → eager-loading includes) honours
all three: it never batch-loads a non-includable relation, bounds its own recursion by
the same effective depth, and skips a path the root's whitelist excludes (so a mutual
default-include cycle terminates the batcher too, not only the renderer).
Polymorphic relationships¶
A MorphTo to-one and a MorphToMany to-many point across several related
types, so the member's serializer is resolved per-object rather than from a
single declared type. Core's PolymorphicSerializer and per-object resolution
own the rendering
(core related-endpoints);
the bundle wires the data path and splits responsibility between the two
providers.
Polymorphic to-one (MorphTo)¶
FavoriteResource
declares favoritable over three types:
MorphTo::make('favoritable', ['tracks', 'albums', 'artists'])
->extractUsing(static fn(mixed $favorite): ?object => $favorite instanceof Favorite ? $favorite->favoritable : null),
The handler resolves the to-one serializer from the actual related object, so
the same relation renders an albums resource for one favorite and an artists
resource for another, and an empty target renders data: null. Because the
target spans entity classes, the Doctrine read leaves the non-mapped property
null; a thin custom provider —
FavoriteProvider
— delegates the fetch to the Doctrine provider, then fills $favoritable from the
entity's stored targetType/targetId pair across per-type repositories (sharing
the EntityManager, so the member comes back managed). The
PolymorphicTest
witnesses GET /favorites/1/favoritable → a tracks member, /favorites/2/… →
an albums member, /favorites/3/… → an artists member.
Polymorphic to-many (MorphToMany)¶
LibraryResource
declares MorphToMany::make('items', ['tracks', 'albums', 'artists']) — a
mixed collection. The provider responsibilities differ by storage:
- The in-memory provider supports it: it reads the mixed collection off the
parent and slices with
page. A polymorphic to-many carries no shared filter/sort vocabulary, sofilter/sortare a400whilepagewindows. - The Doctrine provider throws "unsupported" for a polymorphic to-many — members span entity classes, so no single scoped query can fetch them. You supply a custom provider (→ custom-data-providers.md).
The example app demonstrates the escape hatch with
LibraryItemsProvider,
which resolves the mixed libraries.items members across their per-type
repositories. It registers for two types so every entry point sees the same
members: for libraries it delegates the parent fetch to Doctrine then populates
the non-mapped Library::$items (so the linkage endpoint and ?include read the
mixed list off the parent), and for tracks it answers fetchRelatedCollection()
(the related-collection dispatch resolves a provider by the relation's first
declared type). The members render through a PolymorphicSerializer that
discriminates each by its own type. PolymorphicTest witnesses
GET /libraries/1/items → [tracks:1, albums:2, artists:1], the matching
linkage endpoint, and ?include=items yielding the three mixed included
resources.
Flattened (on()) attributes and eager loading¶
A scalar attribute can be flattened from a chain of declared, to-one relations'
related model with ->on('path') — the value reads from / writes onto the final
related object in the chain, not the owning one. $path is a .-separated chain:
'author' (single hop) or 'publisher.country' (multi-hop). A backing relation may be
hidden() (the idiomatic "internal association"), so the flattened attribute appears on
the wire but the relation never renders as a relationship:
public function fields(): array
{
return [
Id::make(),
Str::make('title')->sortable(),
// `authorName` flattens the author's `name`: read renders author.name, write
// mutates the loaded author's name. storedAs('name') maps the field's backing
// member; on('author') names the to-one relation (honouring its column()/storedAs()).
Str::make('authorName')->storedAs('name')->on('author'),
// Multi-hop: `authorCountry` flattens author.country.name — the chain walks two
// to-one hops. Any intermediate null short-circuits the read to null (and 422s a
// write). Each hop is a declared to-one relation, validated at boot.
Str::make('authorCountry')->storedAs('name')->on('author.country'),
// A computed, read-only attribute (read-only on create AND update): the closure
// owns the output, no serialize cast.
Str::make('display')->computedUsing(
static fn(mixed $model, JsonApiRequestInterface $request, string $name): string
=> 'Book: ' . $model->title,
),
// The hidden backing relation: never a rendered relationship, but eager-loaded
// for the flattened read/write (it backs `authorName` and the first hop of
// `authorCountry`).
BelongsTo::make('author', 'authors')->hidden(),
];
}
The trio is orthogonal: a plain attribute (Str::make('title')), a flattened one
(->on('path')), and a computed one (->computedUsing($closure)) — on() and
computedUsing() are mutually exclusive. The vocabulary is core's; see the core
field-types page.
Read flattens model.seg1.…segN.member (any intermediate null yields a null attribute
value, lenient). Write mutates the loaded final related model in place — Doctrine's
unit of work auto-persists the dirty entity on flush, the in-memory store shares the
reference, no related-persister change. A write where any hop is null is a 422
(RELATED_ATTRIBUTE_OWNER_MISSING, at /data/attributes/<name>) — a flattened attribute
never auto-instantiates a missing related model.
Eager loading. The bundle eager-loads every on() attribute's backing relation chain
so the flattened read does not N+1 — one batched load per hop per page (a multi-hop chain
loads in O(depth), a shared prefix loads once), the same batch as ?include (see
doctrine.md → eager-loaded flattened attributes).
The eager set (the dedup set of every on() chain) is never rendered as a relationship
or in included unless also ?include'd, and bypasses the client-include safeguards
(depth cap / allowed-paths / cannotBeIncluded) — it is author-declared and trusted, not
client input.
Fail-loud, validated at boot. A malformed
on()chain fails the build — atcache:clear/ deploy, never as a runtime 500. Every segment must be a declared to-one relation: a to-many segment at any depth throws aLogicExceptionat container warm-up (on()flattens a scalar from a to-one chain — a to-many is not flattenable; use?includeto materialise a collection), and the rule applies to every segment of a multi-hop chain, not just the leaf. A chain naming an unknown relation (a typo) also throws, so it never silently no-ops. A segment may behidden()or visible — both are valid, because the chain is to-one.
Relations without a resource¶
Relations are a standalone capability: you can declare a type's relations with no
AbstractResource. Put #[AsJsonApiRelations(type: …)] on a class
implementing RelationsProviderInterface —
its relations(): array returns the type's RelationInterface list:
#[AsJsonApiRelations(type: 'libraries')]
final class LibraryRelations implements RelationsProviderInterface
{
public function relations(): array
{
return [
BelongsTo::make('owner', 'users'),
MorphToMany::make('items', ['tracks', 'albums', 'artists']),
];
}
}
Autoconfiguration tags it haddowg.json_api.relations (RELATIONS_TAG); the
bundle holds it in a lazy, type-keyed RelationsRegistry (type-keyed, not
class-string-keyed, because relations are runtime objects core cannot read
statically). The route loader gates the relationship routes on a type having
relations — from a resource or a standalone provider — and
TypeMetadataResolver sources relations resource-first then from the registry, so
a resource-less type (paired with #[AsJsonApiSerializer])
gets identical relationship routes, rendering, and whole-resource relationship
writes. See capability-composition.md for the wiring
rationale.
The server argument on #[AsJsonApiRelations] assigns the type to the named
server(s), the same as the resource attribute (→ multi-server-and-testing.md).
The example app declares all relations on resources, so the standalone form is shown here in prose; the bundle's
RelationsRegistry/TypeMetadataResolverpath is the same one resources take.
Next / see also¶
- routing.md — the relationship/related route names and the
segment-count ordering that keeps
relationshipsfrom being captured as a{relationship}name. - data-layer.md — the
mutateRelationship()andfetchRelatedCollection()SPI signatures and the whole-resource relationship-strip flow. - doctrine.md — the FK-vs-
IN-subquery related-collection scoping and the load-state seam. - custom-data-providers.md — supplying a provider for a polymorphic to-many or any related collection the Doctrine reference cannot scope.
- Core: relations, related-endpoints, relationship-mutation.