Relationships¶
Declare a relation on a resource and you get its linkage, its self/related links, the
related and relationship endpoints, ?include, and (for a countable relation) ?withCount —
all by convention. The relation name maps to an Eloquent relationship method on the model. The
relation DSL is core's — see core
relations; this page covers
the Laravel behaviour around it.
Declaring relations¶
Relations are fields. To-one is BelongsTo; to-many is HasMany / BelongsToMany;
polymorphic is MorphTo / MorphToMany:
use haddowg\JsonApi\Resource\Field\BelongsTo;
use haddowg\JsonApi\Resource\Field\HasMany;
BelongsTo::make('artist', 'artists'); // to-one → the `artists` type
HasMany::make('tracks', 'tracks')->countable(); // to-many, countable
The second argument is the target type (the JSON:API type of the related resource), which
need not equal the relationship method name — BelongsTo::make('publicOwner', 'public-profiles')->storedAs('owner')
reads the owner relation but renders the curated public-profiles type (one model, two
types).
The endpoints¶
For each relation you get, gated by exposure flags:
GET /api/albums/1/tracks # the RELATED resource(s)
GET /api/albums/1/relationships/tracks # the linkage
PATCH /api/albums/1/relationships/artist # replace a to-one
POST /api/albums/1/relationships/tracks # add to a to-many
DELETE /api/albums/1/relationships/tracks # remove from a to-many
Linkage, links, and load state¶
Every relation renders self/related links by convention (withoutLinks() opts out).
Whether linkage data is rendered depends on load state: a lazy to-many emits links-only
without forcing a fetch (dataOnlyWhenLoaded()), driven by a storage-aware load-state seam —
on Eloquent, relationLoaded(). An empty to-one renders data: null.
?include¶
Compound documents assemble through a provider-agnostic batcher over the SPI (not top-level
with() — eloquent):
- Default includes — a resource renders includes for an id request with no
?includeviagetDefaultIncludedRelationships()(the example'salbumsridesartist). An explicit?includesuppresses the default. - Depth cap —
?includedepth is bounded byjsonapi.max_include_depth(a resource overrides it); the example caps at 2. - Path whitelist —
getAllowedIncludePaths()restricts the dotted paths from a root type (the example'susersallowsplaylists,playlists.owner,librarybut notplaylists.tracks→400). - Per-relation opt-out —
cannotBeIncluded()keeps?include=favoritablea400while the related/relationship endpoints still render (the example'sfavorites.favoritable).
?withCount (the Countable profile)¶
A countable() to-many can report its size from the primary request, pushed down as a COUNT
(no materialisation):
Opt in by negotiating the Countable profile. A relation that is not countable() (the
example's count-free orderedTracks pivot) returns a 400 to ?withCount.
Relation-scoped filters, sorts, and pagination¶
A relation may declare filters/sorts/pagination that apply only to its endpoint, distinct from the related resource's own set:
BelongsTo::make('artist', 'artists')
->withFilters(Where::make('name', 'name'));
HasMany::make('tracks', 'tracks')
->paginate(PagePaginator::make()->withDefaultPerPage(2))
->withFilters(Where::make('longerThan', 'length_seconds', '>')->integer())
->withSorts(SortByField::make('duration', 'length_seconds'))
->countable();
The Relationship Queries profile¶
Order and narrow a relation's linkage from the primary request (not the related endpoint), opt-in by negotiating the profile:
Windowing per parent uses the SQL push-down described in
eloquent. A relationship
endpoint query it cannot honour is a 400, never silently ignored
(ADR 0010).
Pivot (belongsToMany) data¶
A BelongsToMany over a join table with columns renders them as meta.pivot, validates and
upserts them through linkage meta, and exposes author-declared pivot filters. The example's
playlists.orderedTracks carries position/weight/addedAt:
BelongsToMany::make('orderedTracks', 'tracks')
->fields(
Integer::make('position')->required()->min(1),
Integer::make('weight')->compareWith('position', Comparison::GreaterThanOrEqual),
DateTime::make('addedAt')->storedAs('added_at')->readOnly(),
)
->withFilters(Where::make('position', 'pivot.position'), Where::make('weight', 'pivot.weight'))
->paginate(PagePaginator::make()->withDefaultPerPage(2));
position is required-on-create, weight is a second writable pivot field constrained
weight >= position, and addedAt is server-owned (readOnly). Pivot fields are merged into
the payload before validation, so cross-field rules see them. (Pivot-meta read rendering
is described in
ADR 0008.)
Relationship mutations and prohibitions¶
A relation can prohibit a full-replacement or a removal:
cannotReplace() makes a PATCH …/relationships/playlists (full replacement) a 403
(FullReplacementProhibited); cannotRemove() makes a DELETE a 403. Embedded
belongs-to-many writes in a whole-resource POST are applied after the parent is created
(ADR 0009).
Polymorphic relations¶
MorphTo(to-one) — the member's type is resolved from the related object (ADR 0007). On Eloquent it is a nativemorphTo; the example'sfavorites.favoritablepoints at a track, album, or artist.MorphToMany(to-many) — a mixed collection where each member renders through its own per-type serializer. Where the Doctrine reference throws, the Eloquent reference resolves it natively (morphedByManyover one polymorphic pivot) — the over-parity headline. The example'slibraries.itemsmixes tracks + albums + artists:
MorphToMany::make('items', ['tracks', 'albums', 'artists'])
->extractUsing(static fn(mixed $library): array => $library->libraryItems());
A polymorphic to-many carries no shared filter/sort vocabulary, so ?filter/?sort on its
endpoint are a 400 while ?page slices.