Related and relationship read endpoints¶
Every relationship you declare exposes two read endpoints: a related read
(GET /{type}/{id}/{rel}) that returns the full related resource(s), and a
relationship read (GET /{type}/{id}/relationships/{rel}) that returns just
the linkage — type + id identifiers, no attributes. This page shows how to
serve both, including the paginated, polymorphic, and empty-to-one cases.
The two endpoints answer different questions. The related read is "give me the artist of this album"; the relationship read is "which artist id does this album point at". You choose between them by URL, and the library renders each through a different response value object.
New here? Start with getting-started and relations. Installation is covered in index.md.
Two endpoints, two operations, two responses¶
Both endpoints map to a no-body operation that carries the parent's
Target (type, id, relationship name) plus the request's
query parameters:
| Endpoint | Operation | Response VO | data |
|---|---|---|---|
GET /{type}/{id}/{rel} |
FetchRelatedOperation |
RelatedResponse |
the full related resource(s) |
GET /{type}/{id}/relationships/{rel} |
FetchRelationshipOperation |
IdentifierResponse |
linkage identifiers only |
Your handler dispatches on the operation type. The worked
handler in the example app —
MusicCatalogHandler —
handles both in one match (true):
$operation instanceof FetchRelatedOperation => $this->fetchRelated($operation),
$operation instanceof FetchRelationshipOperation => $this->fetchRelationship($operation),
// …
The related read¶
For a related read you load the parent, resolve the named relation, read the
related value off the parent, and render it through the related type's
serializer. The single-resource form uses
RelatedResponse::fromResource():
$parent = $this->loadParent($type, $target->id);
// …
$relation = $server->resourceFor($type)->relationNamed($relationshipName);
if ($relation === null || !$relation->exposesRelatedEndpoint()) {
return ErrorResponse::fromException(new RelationshipNotExists($relationshipName));
}
$related = $relation->readValue($parent, $request);
// …
$serializer = $relation->resolveSerializer($related, $server) ?? $server->serializerFor($relatedType);
return RelatedResponse::fromResource($related, $serializer);
GET /albums/1/artist returns the artist resource in full:
The links on a RelatedResponse are scoped to the related URL the client hit
(/albums/1/artist), not to a primary /artists collection.
A to-many related read¶
When the relation is to-many, render the related list with
RelatedResponse::fromCollection() (or
fromPage() when it paginates — see below). GET /albums/2/tracks returns the
tracks of album 2:
An empty to-one renders data: null¶
A to-one relation with no related object renders data: null, not a 404 — the
relationship exists, it is just empty. Radiohead's featuredAlbum is set, but
Portishead's is not:
// GET /artists/2/featuredAlbum → Portishead has no featuredAlbum.
self::assertNull(JsonApiDocument::of($response)->data());
In the handler this falls out naturally: readValue() returns null,
resolveSerializer() falls back to the first registered serializer, and
RelatedResponse::fromResource(null, $serializer) renders data: null.
The relationship (linkage) read¶
The relationship read returns only identifiers. You route the parent through
the parent's serializer, naming the relationship, with
IdentifierResponse::forRelationship():
$relation = $server->resourceFor($type)->relationNamed($relationshipName);
if ($relation === null || !$relation->exposesRelationshipEndpoint()) {
return ErrorResponse::fromException(new RelationshipNotExists($relationshipName));
}
return IdentifierResponse::forRelationship($parent, $server->serializerFor($type), $relationshipName);
forRelationship(parent, parentSerializer, relName) transforms the parent with
the relationship name as the requested relationship, so the transformer emits
linkage. GET /albums/1/relationships/artist returns one identifier with no
attributes:
GET /albums/1/relationships/tracks returns a list of identifiers — three
{ "type": "tracks", "id": … } objects, none carrying attributes.
Paginated related collections¶
A to-many related collection paginates per relation. The album→tracks relation
declares a paginator in AlbumResource:
The handler resolves the paginator with a three-step fallback — the relation's
paginator, else the related resource's, else the server default — threaded
through pagination() (the resolved fallback is passed in; the relation can also opt
out with withoutPagination()) — and renders with
RelatedResponse::fromPage():
$fallback = $relatedResource?->pagination($server->defaultPaginator())
?? $server->defaultPaginator();
$paginator = $relation->pagination($fallback);
// …
if ($result instanceof PageInterface) {
return RelatedResponse::fromPage($result, $serializer);
}
Album 1 has three tracks, so this example's repository (which counts its in-memory
slice) returns a first page of two, with meta.page.total of three and
next/last links scoped to /albums/1/tracks:
self::assertCount(2, $this->collection($response));
self::assertSame(3, $page['total'] ?? null);
self::assertStringContainsString('/albums/1/tracks', $this->href($links['next']));
Counting is opt-in: a count-based paginator is count-free by default (no
meta.page.total, nolast,nextviahasMore, zeroCOUNT), and a total is emitted only when the relation's paginator is->withCount()or the client requests the relation's countable total — see Counting and totals. This example's repository computes the count eagerly, so its related endpoint always carries the page total.
fromPage() paginates the related collection exactly as
DataResponse::fromPage() paginates a primary collection — same
links.{first,prev,self,next,last} and meta.page, scoped to the related URL.
See pagination for the per-relation resolution in full.
Polymorphic related endpoints¶
A polymorphic relation (MorphTo / MorphToMany) renders through the same
FetchRelated / FetchRelationship operations and the same response VOs — the
polymorphism is resolved in the serializer, not the operation.
MorphTo: a polymorphic to-one¶
FavoriteResource
declares favoritable as a MorphTo over three types:
MorphTo::make('favoritable', ['tracks', 'albums', 'artists'])
->extractUsing(static fn(mixed $favorite): ?object => $favorite instanceof Favorite ? $favorite->favoritable : null),
The to-one resolves its serializer from the related object via
RelationInterface::resolveSerializer(),
so the same endpoint shape renders a different type per favorite:
$serializer = $relation->resolveSerializer($related, $server) ?? $server->serializerFor($relatedType);
return RelatedResponse::fromResource($related, $serializer);
GET /favorites/1/favoritable resolves a track, /favorites/2/favoritable
resolves an album, /favorites/3/favoritable resolves an artist — each carrying
its own type. The relationship read picks up the resolved type in the linkage:
GET /favorites/2/relationships/favoritable yields { "type": "albums", "id": "1" }.
MorphToMany: a polymorphic to-many¶
LibraryResource
declares items as a MorphToMany. The handler detects a polymorphic relation
(more than one related type) and renders its mixed members through a
PolymorphicSerializer decorator that resolves the per-member
serializer:
$relatedTypes = $relation->relatedTypes();
$polymorphic = \count($relatedTypes) > 1;
// …
$serializer = $polymorphic
? $this->polymorphicSerializer($relation, $server)
: $server->serializerFor($relatedType);
private function polymorphicSerializer(RelationInterface $relation, Server $server): PolymorphicSerializer
{
return new PolymorphicSerializer(
static fn(mixed $object): SerializerInterface => $relation->resolveSerializer($object, $server)
?? throw new \LogicException(/* … */),
);
}
GET /libraries/1/items returns a track, an album, and an artist, each with its
own type. The relationship read renders mixed linkage the same way.
A polymorphic to-many carries no shared filter or sort vocabulary — the
members span entity classes, so there is no common column to filter or sort by.
The handler rejects either with a 400, but page still slices the mixed list:
if ($polymorphic) {
$unsupported = match (true) {
$operation->queryParameters()->filter !== [] => 'filter',
$operation->queryParameters()->sort !== [] => 'sort',
default => null,
};
if ($unsupported !== null) {
return ErrorResponse::fromException(new \haddowg\JsonApi\Exception\QueryParamUnrecognized($unsupported));
}
}
// page slices the mixed collection even though filter/sort cannot.
$response = $this->get('/libraries/1/items?page[number]=1&page[size]=2');
self::assertCount(2, $this->collection($response));
$this->get('/libraries/1/items?filter[title]=anything'); // → 400
$this->get('/libraries/1/items?sort=title'); // → 400
The in-memory provider supports the polymorphic mixed read (it reads the mixed collection off the parent). The Symfony bundle's Doctrine provider throws "unsupported" for a polymorphic to-many — its members span entity classes, so there is no single repository to scope — and you supply a custom provider.
Endpoint exposure¶
By default both endpoints exist for every relation. Suppress one at declaration
time with withoutRelatedEndpoint() or withoutRelationshipEndpoint() (see
relations). The handler enforces a suppressed endpoint as a
404 and the matching links member is omitted, so a hidden endpoint is hidden
both at the URL and in the linkage. The guards in the worked handler are
exposesRelatedEndpoint() and exposesRelationshipEndpoint():
if ($relation === null || !$relation->exposesRelatedEndpoint()) {
return ErrorResponse::fromException(new RelationshipNotExists($relationshipName));
}
Compound includes¶
Both endpoints honour ?include, so you can pull a related resource and its own
relations in one request. GET /albums/1/artist?include=albums returns the
related artist plus that artist's albums in included:
$response = $this->get('/albums/1/artist?include=albums');
JsonApiDocument::of($response)
->assertHasType('artists')
->assertHasIncluded('albums');
See sparse-fieldsets-and-includes for the full inclusion and sparse-fieldset rules.
Filtering a relationship's linkage from the primary request¶
These endpoints filter and sort their related collection with the plain
filter[…] / sort= parameters. To filter or sort a relationship's linkage
from a primary request instead — whether it renders via ?include, as
links-only linkage, or at its own endpoint — negotiate the
relationship-queries profile: relatedQuery[<path>][filter][<key>]=… /
[sort]=… (shorthand rQ), keyed by the relationship's include path. See
profiles and the
profile specification.
Next / See also¶
- relations — declaring relations, linkage rendering, and endpoint exposure.
- relationship-mutation — the write twin:
PATCH/POST/DELETE .../relationships/{rel}. - responses —
RelatedResponse,IdentifierResponse, and the shared withers. - pagination — the per-relation paginator resolution.
- serializers — the
PolymorphicSerializerdecorator.