Relationships
Martis has a field for every Eloquent relationship, from BelongsTo and HasMany to the polymorphic MorphTo and MorphToMany. This guide covers how each one is declared, how it renders (a picker, a card or an inline panel) and how it is authorized.
Overview
Martis provides the full set of relationship field types that map 1:1 to Laravel Eloquent relationships:
| Field | Eloquent Relationship |
|---|---|
BelongsTo | belongsTo() |
HasOne | hasOne() |
HasOneOfMany | hasMany()->latestOfMany() / ofMany(...) |
HasOneThrough | hasOneThrough() |
HasMany | hasMany() |
HasManyThrough | hasManyThrough() |
BelongsToMany | belongsToMany() |
Tag | belongsToMany() (chip UI) |
MorphTo | morphTo() |
MorphOne | morphOne() |
MorphOneOfMany | morphMany()->latestOfMany() / ofMany(...) |
MorphMany | morphMany() |
MorphToMany | morphToMany() |
Toolbar hide flags (cross-cutting)
All relationship fields share the ControlsRelationshipToolbar trait, which
exposes nine ->hideXxx() setters that let a programmer hide a piece of
the panel's UI. Visibility composes with authorization as:
visible = authorized AND NOT hidden
Authorization is always the source of truth — the setters cannot force
something to appear. Unauthorized actions are never shown. An action is
authorized when the field allows it (canCreate() / canUpdate() /
canDelete()) and, for an action on a listed record (View, Edit, Delete,
Restore, Force delete on a row, Edit and Delete on a HasOne / MorphOne
card), when the related resource's policy allows it for that record: each
record carries those answers under _authorization, and a record without them
keeps the action, as on the resource index. The id column links to the record
only when its authorizedToView allows it (v2.0).
The canCreate() / canUpdate() / canDelete() setters (HasMany,
HasOne, MorphMany, MorphOne) and canAttach() / canDetach()
(BelongsToMany, MorphToMany) are enforced on the server (v2.4.0). A
field that turns a write off does not only hide its button: a request that
names the relationship directly (POST, PUT or DELETE /api/resources/\{resource}/\{id}/has-many/\{relationship}, the has-one,
morph-many and morph-one routes, and the belongs-to-many and
morph-to-many attach (one record or a batch), attachable, pivot picker
and detach routes) answers 403 and writes nothing. The setters narrow the
policies, they never widen them: a write a flag leaves on still needs the
policy abilities. Before v2.4.0 the flags only hid the button, so a panel
configured read-only (HasMany::make('Invoices')->canDelete(false)) still
deleted through the endpoint for a user whose policy allowed it. Hiding a
control with the hideXxx() setters stays cosmetic.
When the related resource denies viewAny, the panel is not on the detail
page at all (v2.0.1+, see below), so it offers no Create, Edit, Delete,
Restore or Force delete either: every one of those writes needs the related
viewAny (see
Authorization → viewAny is the entry gate).
On BelongsToMany / MorphToMany the row's own View / Edit / Delete are
replaced by Detach and the pivot edit (which hideDeleteAction() /
hideEditAction() hide), so hideViewAction() has nothing to hide;
hideSoftDeleteToggle(), hideRestoreAction() and hideForceDeleteAction()
apply there too since v2.0 (1.x ignored them on these panels).
| Setter | Hides |
|---|---|
->hideSearch() | Search input in the panel toolbar |
->hideCreateButton() | Create / Attach button |
->hidePerPageSelector() | "Per page" dropdown |
->hideSoftDeleteToggle() | Active / With trashed / Only trashed dropdown |
->hideViewAction() | Eye icon in the actions column |
->hideEditAction() | Pencil icon in the actions column |
->hideDeleteAction() | Trash icon in the actions column |
->hideRestoreAction() | Restore icon on trashed rows |
->hideForceDeleteAction() | Force-delete icon on trashed rows |
HasMany::make('Comments', 'comments') ->hideSoftDeleteToggle() // never show trashed filter ->hideForceDeleteAction() // permanent deletion is never exposedPanels follow the related resource's viewAny (v2.0.1+)
A HasMany, HasOne, MorphMany, MorphOne, BelongsToMany or
MorphToMany field (and HasManyThrough, HasOneThrough, HasOneOfMany,
MorphOneOfMany, built on them) is seen only by a user whom the related
resource lets viewAny, as in Nova, whose relationship fields authorize with
$resourceClass::authorizedToViewAny($request) && parent::authorize($request):
- the detail page (and the schema, the drawer, a one-of-many card) leaves the
field out, wherever it is declared (a
Panel, a tab); - its routes (the list or card, the attachable list, attach, detach, pivot
update, the pivot actions and their pickers, and the writes) answer
403, as Nova's relationship index (the related resource's index) does. A field its owncanSee()/canSeeForModel()hides keeps answering404; - the field's
canSee()still applies on top: both must allow it.
The automatic Action Events panel of an Actionable model follows the same
rule (see Actions → The Action Events panel).
Before v2.0.1 the panel stayed on the page and listed the related records to
any user who could view the parent. To show a panel to a user, grant viewAny
on its related resource and confine the rows with indexQuery().
Relationship panel anatomy
All -Many relation panels (HasMany, MorphMany, BelongsToMany, MorphToMany
and their Through variants) are rendered by a single shared React component,
RelationshipTableShell. Understanding its layout makes the hide flags and
pivot slots predictable across fields.
resources/js/components/fields/relation/RelationshipTableShell.tsx
Panel layout
From top to bottom:
- Heading row — related-resource icon (from
ResourceIcon), the field title, and a count badge with the total number of related rows. Whencollapsable()is on, the heading also carries the caret toggle. - Toolbar — search input, primary Create (or Attach) button, an optional slot for consumer-supplied extras, the per-page selector, and the soft-delete dropdown (Active / With trashed / Only trashed).
- DataTable — the list itself (see columns below).
- Pagination — rendered only when the server returns a paginator.
Responsive toolbar (container queries)
The toolbar reflows using CSS container queries on the class
martis-relation-toolbar, not viewport media queries. This means the layout
adapts to the width of the panel's container, so a relation panel inside a
narrow tab wraps even when the window is wide.
| Container width | Layout |
|---|---|
>= 48rem | Single row: heading on the left, [search + create + extras + per-page + trashed] on the right. |
< 48rem | Two rows: row 1 = heading + search + create; row 2 = per-page on the left, soft-delete dropdown on the right. |
Columns (rendered in order)
- Selection checkbox — only when
selectableis passed (used byBelongsToMany/MorphToManyfor bulk pivot actions). - "Archived" chip — only rendered when at least one row in the current
page has a non-null
deleted_at. Pure visual tag, no action. indexFields— resolved from the related resource'sfieldsForIndex(). Rendered throughFieldDisplay.pivotFields—BelongsToManyandMorphToManyonly. Each pivot column reads its value fromrow._pivot.{attribute}.- Actions column — View / Edit / Delete, plus Restore and Force-delete
on trashed rows, plus any
rowActionsExtras(row)the consumer returns, plus (onHasMany/HasManyThrough/MorphMany, v2.0) the related resource's inline actions menu, as on its index (see Actions → In relationship panels). The column auto-collapses when a row would render zero actions: a fully read-only panel (all row actions hidden/unauthorized, and for pivot relationscanAttach(false)+canDetach(false)with no pivot editing) renders no trailing "Actions" header or empty cells. Since v1.31.1.
Trashed rows are rendered with opacity-60 and swap Edit/Delete for
Restore + Force-delete (icons only; confirmation modals are handled by the
shell itself).
Slots consumers plug into
| Slot | Type | Purpose |
|---|---|---|
pivotFields | FieldDefinition[] | Extra columns pulled from row._pivot.*. BelongsToMany/MorphToMany pipe their pivot fields in here. |
selectable + selectedRows + onSelectionChange | boolean + ResourceRecord[] + callback | Controlled multi-select. When selectable is on the shell renders a checkbox column and reports the selection upward. |
toolbarExtras | ReactNode | (ctx: { selectedRows }) => ReactNode | Rendered in the primary toolbar after Create. The render-prop form receives the current selection — BelongsToMany/MorphToMany use it to show pivot action dropdowns and an Attach button with a "N selected" counter. |
rowActionsExtras | (row) => ReactNode | Per-row extras, appended to the action icons. |
createUrl / editUrl / viewUrl / deleteUrl | URL builders | Endpoint/route overrides so the same shell powers HasMany (inline CRUD) and BelongsToMany (attach/detach) transparently. |
Field ↔ slot usage matrix
| Field | Uses | Why |
|---|---|---|
HasMany / HasManyThrough | (shell defaults only) | Plain inline CRUD; no pivot, no multi-select. |
MorphMany | (shell defaults only) | Same as HasMany but polymorphic. |
BelongsToMany | pivotFields + selectable + toolbarExtras(ctx) + rowActionsExtras | Pivot columns + bulk pivot actions + Attach button + per-row Detach/edit-pivot. |
MorphToMany | pivotFields + selectable + toolbarExtras(ctx) + rowActionsExtras | Same as BelongsToMany, polymorphic pivot. |
HasOne / MorphOne / *OfMany / HasOneThrough | n/a — uses a dedicated single-record panel, not the shell. |
Keep in mind: visible = authorized AND NOT hidden. The shell never
up-grades an unauthorized action; the hideXxx flags only subtract.
Which record a panel belongs to
Every relationship panel (the -Many panels above and the single-record
HasOne / MorphOne cards, *OfMany and Through variants included) lists
the related records of the record it belongs to, and builds every URL from
it: the list, the pivot actions, the attach picker, Create / Edit links
(viaResource / viaResourceId) and Delete / Detach.
| Where the panel renders | The record it belongs to |
|---|---|
Top level of a detail page, or an edit form (BelongsToMany / MorphToMany) | The record in the URL (/resources/{resource}/{id}). |
Among the fields of a HasOne / MorphOne card (*OfMany, HasOneThrough) | The card's related record. |
Inside a bundled record drawer (DrawerDetail, DrawerUpdate, DrawerQuick) | The record the drawer shows, which the page URL may not name (a lens row, an index row or an action response opens it). |
On a create surface (the create page, DrawerCreate, the inline-create modal) | None: the record does not exist yet, whatever page or drawer the surface opens over. The schema keeps BelongsToMany / MorphToMany off every create form, like Nova, and a pivot panel with no record renders nothing and asks nothing. |
| Inside a custom override, Tool or card | The record it names with NestedParentProvider from @martis/runtime (id: null on a create form), else the record in the URL. See overrides.md § Naming the record of the relationship panels. |
On team-members/2, a HasOneThrough card showing project 3 renders the
project's HasMany tasks from /api/resources/projects/3/has-many/tasks, and
its Create button opens /resources/tasks/create?viaResource=projects&viaResourceId=3&….
The query string also carries from, the page the button was clicked on, and
Save and Cancel return there. Since v2.0.1 only a same-origin path is followed
(/…): a from that starts with // or holds a backslash or a control
character points at another origin once the browser parses it, so the form
ignores it and returns to its default destination. A link crafted with
from=//attacker.example can no longer send the user off the panel after a
save (React Router 6 does not filter these values, GHSA-wrjc-x8rr-h8h6).
That form posts to the relationship's endpoint, as multipart when it carries a
file (v1.38.0+; before, it always posted JSON and a picked file was lost).
Since v2.4.0 the endpoint is built from the link's query string safely:
viaResource, viaResourceId and viaRelationship are each encoded as one
path segment, and viaRelationshipType must be one of the endpoints that store
a nested record (has-many, has-one, morph-many, morph-one; has-many
when the link names none). A link with another type is treated as a plain
create: the via* parameters are ignored and the form posts to the resource
itself. A crafted link could otherwise turn the click on Create into a
credentialed POST to any other endpoint of the panel (client-side path
traversal).
Since v1.38.0: before it, only the HasOne / MorphOne cards honoured the
enclosing card, so a HasMany, MorphMany, BelongsToMany or MorphToMany
nested in a card or rendered in a drawer asked the page's record (404, or the
page record's rows when it declares the same relationship). A BelongsToMany /
MorphToMany declared with showOnCreating() also rendered on the create
forms and read the page: /api/resources/{resource}//belongs-to-many/... (404)
on the create page, and the page's record in a create drawer or modal opened
over another record, so a Replicate drawer listed, and attached to, the record
it copies.
How a panel finds its parent record
Every panel endpoint checks the resource's viewAny, finds the record in the
URL ({resource}/{id}), then checks view on it. The lookup differs by panel:
| Endpoints | The parent is found |
|---|---|
The BelongsToMany panel: its list, attachable list, attach, detach and pivot update | Through the resource's scopes() and indexQuery(), as its index lists it: a parent they hide answers 404, like a missing one, even for a resource with no policy. |
The pivot routes of the BelongsToMany and MorphToMany panels: the pivot actions, their fields and pickers, and the pickers of the pivot fields | The same way. |
The HasMany, HasOne, MorphMany and MorphOne panels, and the list, attachable list, attach, detach and pivot update of the MorphToMany panel | By its key alone, as its detail page finds it: the parent's view policy decides, as in Nova's model, so a record the index hides is reachable there when the policy allows it. |
A resource that confines its records with scopes() or indexQuery()
confines these parents with its view policy too. scopes() joined the first
two rows in v2.0 (they ran indexQuery() alone). See Authorization →
Declarative query scopes.
Soft-delete filter
Relationship panels whose related resource uses SoftDeletes automatically
render a three-state filter in the toolbar — Active / With trashed /
Only trashed. Trashed rows show Restore and Force-delete actions
instead of Edit/Delete.
The default state comes from config/martis.php under the index block:
// config/martis.php'index' => [ 'default_trashed_filter' => env('MARTIS_DEFAULT_TRASHED_FILTER', 'active'), // 'active' | 'with' | 'only'],Visibility follows the usual gate — Resource::canViewTrashed() must return
true (default) AND the programmer must not call ->hideSoftDeleteToggle().
The filter applies on every panel, the BelongsToMany and MorphToMany
ones included (v2.0; before, their endpoints ignored ?trashed, so Only
trashed listed the active records). Nova's BelongsToMany panel has the
same filter (nova-dusk-suite: UpdateAttachedSoftDeletingTest).
Every panel also lists only the rows the related resource's index would:
its scopes() and indexQuery() apply (v2.0), within the relationship's
own definition: a global scope the relationship removes
(->withoutGlobalScope(ArchivedScope::class), ->withoutGlobalScopes())
stays removed on the panel and in its counts. See
Resources → indexQuery().
BelongsTo
A many-to-one relationship. Stores a foreign key on the parent model and displays the related record's title.
BelongsTo::make('user_id', 'Author') ->relatedResource('users') ->titleAttribute('name') ->displayAsLink() ->sortable()See fields.md — BelongsTo for full API reference.
HasMany
A one-to-many relationship. Renders as an inline DataTable on the detail page with full CRUD (create, edit, delete) for child records.
HasMany::make('Comments', 'comments') ->relatedResource('comments') ->collapsable() ->collapsedByDefault()See fields.md — HasMany for full API reference.
BelongsToMany
A many-to-many pivot relationship field. Renders as a DataTable panel on the detail page and the update form with attach/detach, pivot field editing, search, and pagination. It never renders on a create form (the create page, the create drawer, the inline-create modal), like Nova: a pivot row needs the record's key, so no visibility call brings the field there (since v1.38.0). Attach once the record exists, or use Tag to pick related records while creating.
Basic Usage
BelongsToMany::make('Tags') ->relatedResource('tags')With Pivot Fields
Pivot fields are extra columns stored in the pivot table.
Migration:
Schema::create('post_tag', function (Blueprint $table) { $table->foreignId('post_id')->constrained()->onDelete('cascade'); $table->foreignId('tag_id')->constrained()->onDelete('cascade'); $table->string('notes')->nullable(); $table->primary(['post_id', 'tag_id']);});Model:
class Post extends Model{ public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class) ->withPivot(['notes']) ->withTimestamps(); }}Resource:
BelongsToMany::make('Tags', 'tags') ->relatedResource('tags') ->searchable() ->fields(fn () => [ Text::make('notes', 'Notes')->nullable(), ])Pivot fields validate with their own rules, like any field: the attach runs rules() plus creationRules(), and the pivot update runs rules() plus updateRules() with the literal required dropped and sometimes first, so a pivot field the update does not send is left alone. Rule objects (Rule::in(), Rule::unique()), ValidationRule instances and closures run on both. See Fields → What an update validates.
Each pivot value is written through the pivot field's own fill(), run on a pivot model of the relationship's class (the stock Pivot, or the class passed to ->using()), the way a record field is filled and the way Nova fills the pivot. So a pivot field behaves as it does on a record (v1.38.0+):
- a
fillUsing()callback receives the pivot model and decides what to write (several columns included); - a
computed()field writes nothing; - a structured field (
MultiSelect,KeyValue,BooleanGroup) is stored as JSON, or handed to the cast of a custom pivot class and encoded once; - a
Booleanstores a boolean, aBelongsToits foreign key column, aPasswordits hash.
On top of fill(), the pivot endpoints apply the write rules of the resource endpoints:
- an
immutable()pivot field is written on attach and skipped on the pivot update; - a
readonly()pivot field never takes its value from the request: the attach stores itsdefault()when it has one, and the pivot update leaves the column alone; - a pivot field the user cannot see (
canSee()) is written like a readonly one and is not validated, and it is left out of the relationship's schema (so the attach and edit forms do not render it) and of the pivot values sent back: each attached record's_pivotand the pivot update's response (v1.38.0+); - a pivot field hidden for the pivot row by
canSeeForModel()/canSeeUsingPolicy()is written and left out the same way, row by row (v1.38.0+). The callback receives the pivot row, an instance of the relationship's pivot class whosepivotParentis the parent record: the attached row when the pivot values are read or updated, a new row (before any value of the request is written to it) on attach. The relationship's schema still lists the field, so each attached record's_pivotlists it under_hidden(v1.38.0+), and the panel leaves it out of that row's cell (left empty) and of its pivot edit form, which neither renders nor sends it. The attach form renders it: the schema cannot decide on the new row, and the attach ignores the field (it stores itsdefault()); - a pivot
Repeaterwrites its rows as on a record: a row keeps the stored value of a row field it cannot write and a new row takes the field'sdefault(), and its rows are sent back as the Repeater reads them, without the row fields the user cannot see (v1.38.0+, see Repeater → Readonly, computed, hidden and immutable row fields).
The forms match: the attach form keeps an immutable pivot field editable, and the form that edits a pivot row renders it read-only, like a readonly one.
The attach stores the default() of every pivot field it does not take from the request (one the request omits, or a readonly one), so a readonly pivot field with a default stamps the row with a value the client cannot change:
->fields(fn () => [ Text::make('reference')->immutable(), Number::make('added_by')->readonly()->default(fn ($request) => $request?->user()?->id),])A value the request sends for a readonly or immutable field still runs the field's rules (a field the user cannot see is not validated). A pivot update with nothing left to write (an empty body, or only readonly and immutable values) answers 200 and leaves the row as it was. See Fields → Immutable fields.
Before v1.38.0 canSee() and canSeeForModel() on a pivot field were ignored: the field was serialised, listed in _pivot, validated and written like any other. Up to v1.37.3 the attach and the pivot update wrote every pivot value the request sent, readonly and immutable fields included, and a pivot update with nothing to write answered 500 (an UPDATE with an empty SET) unless the relation declared withTimestamps() or using(). The values were copied from the request as they came, so a pivot fillUsing() never ran, a computed pivot field was written to a column that does not exist and a MultiSelect sent its array to the column (both a 500).
Relation pickers among the pivot fields
A BelongsTo, MorphTo or Tag pivot field lists its options from the panel (v1.38.0+). The parent resource's forms do not declare pivot fields, so the attach modal asks GET /api/resources/{resource}/{id}/belongs-to-many/{relationship}/pivot-fields/relatable/{attribute}, and the modal that edits the pivot row of an attached record asks .../pivot-fields/{relatedId}/relatable/{attribute} (morph-to-many for a MorphToMany):
BelongsToMany::make('Members', 'members') ->relatedResource('users') ->fields(fn () => [ BelongsTo::make('role', 'Role')->relatedResource('roles'), ])The field is looked up in the relationship's fields() only (a Repeater row among them included, see Repeater → Relation pickers and remote selects in rows), so its related resource, relatableQueryUsing() and withoutTrashed() apply, and the parent resource is the source of the relatable{PluralModelName}() hook, as for the panel's pivot actions. The two routes are gated like the panel: viewAny on the resource, the parent record found through its scopes() and indexQuery() (a parent they hide answers 404), view on it, and {relationship} resolved only to a relationship field of the route's type the resource declares. Then like the operation the modal performs:
- the attach modal needs
authorizedToAttachAny()for the related model (theattachAny{Model}policy ability), as the list of records to attach and the attach itself do: no record is picked yet, and the attach then checksattach{Model}for each one; - the pivot edit modal needs
authorizedToUpdatePivot()for that record (updatePivot{Model}, falling back toupdate), as the pivot update does.
Then, like every picker, viewAny on the related resource. An attribute the relationship does not declare as a BelongsTo, MorphTo or Tag pivot field, an unknown relationship, a missing parent, or a {relatedId} the relationship does not attach answer 404.
Before v1.38.0 the pickers of a pivot field asked the parent resource's relatable endpoint (
/api/resources/{resource}/{id}/relatable/{attribute}), which reads the parent's forms: 404 and an empty picker.
Pivot Actions
Actions declared on the field with ->actions() run on the rows selected in this relationship's panel, and handle() receives each related model with its pivot row (the pivot fields above included). A resource action flagged ->pivotAction() shows on every BelongsToMany and MorphToMany panel of the resource. See Actions → Pivot Actions.
BelongsToMany::make('Tags', 'tags') ->relatedResource('tags') ->fields(fn () => [ Text::make('notes', 'Notes')->nullable(), ]) ->actions(fn (Request $request) => [ ClearTagNotes::make(), ])Full Configuration
use Martis\Enums\ModalSize; BelongsToMany::make('Roles', 'roles', RoleResource::class) ->searchable() // enable search in attach modal ->collapsable() // make panel collapsable ->collapsedByDefault() // start collapsed ->allowDuplicateRelations() // allow same record attached twice ->showCreateRelationButton() // inline create button in modal ->modalSize(ModalSize::ThreeExtraLarge, '70vh') // size + optional fixed height ->withSubtitles() // show subtitles in search results ->dontReorderAttachables() // keep DB order in attachable list ->relatableQueryUsing(fn ($req, $q) => $q->where('active', true)) ->fields(fn () => [ Text::make('notes', 'Notes')->nullable(), Date::make('expires_at', 'Expires At')->nullable(), ]) ->perPage(15) ->canAttach() // bool toggle — defaults to true ->canDetach() // bool toggle — defaults to truemodalSize() accepts a Martis\Enums\ModalSize case (Small through SevenExtraLarge) and an optional second parameter that pins the modal's height to a fixed CSS value ('70vh', '600px'). Default height tracks the size; pass the second arg only when the size's intrinsic height is wrong for your form.
A model related to itself
On a reflexive relationship (a Project related to other projects through belongsToMany(Project::class, ...)), the attach picker lists every record of the related resource that is not attached yet, the parent record included, as Nova does: Martis does not decide for the application that a record cannot relate to itself. To leave it out, exclude the parent id the route carries ({id}) in a relatable hook:
BelongsToMany::make('Related projects', 'relatedProjects') ->relatableQueryUsing(fn (Request $request, $query) => $query->whereKeyNot($request->route('id')))Since writes follow the pickers, the same hook also refuses the write: attaching the record to itself answers 422 on related_id and nothing is stored. relatable{PluralModelName}() on the resource works the same way when every reflexive field of the resource should exclude it. The MorphToMany panel takes the same closure.
Authorization
canAttach() / canDetach() are static toggles (defaults to true — pass false to turn the write off for everyone: the button is hidden and the endpoints answer 403, v2.4.0). They do not accept closures. For dynamic, request-aware authorization, override the matching method on the parent Resource:
// In your Resource class:public function authorizedToAttach(Request $request, Model $related): bool{ return $request->user()?->can('attach', [$this->model(), $related]) ?? false;} public function authorizedToDetach(Request $request, Model $related): bool{ return $request->user()?->can('detach', [$this->model(), $related]) ?? false;}Without an override they ask the parent's policy: attach{Model} and detach{Model}, permitted when the policy does not define them. Before any record is picked, authorizedToAttachAny() (the attachAny{Model} ability, permitted when undefined) gates the attach as a whole: when it denies, the list of records to attach (.../attachable), the attach itself and the pickers of the attach modal's pivot fields answer 403, while the detach and the pivot update keep their own abilities. The pivot update asks authorizedToUpdatePivot() (updatePivot{Model}, falling back to update). Those two are only asked about a record attached to the parent: one that is not attached answers 404 first, like a missing id (v2.11.1, see Only an attached record is detached).
Before v1.38.0 the attach and the list of records to attach did not check
attachAny{Model}: a user it denied could still attach.
API Endpoints
| Method | Path | Description |
|---|---|---|
GET | /api/resources/{resource}/{id}/belongs-to-many/{relationship} | List attached records |
GET | /api/resources/{resource}/{id}/belongs-to-many/{relationship}/attachable | List attachable records |
POST | /api/resources/{resource}/{id}/belongs-to-many/{relationship}/attach | Attach record |
DELETE | /api/resources/{resource}/{id}/belongs-to-many/{relationship}/{relatedId}/detach | Detach record |
PUT | /api/resources/{resource}/{id}/belongs-to-many/{relationship}/{relatedId}/pivot | Update pivot |
GET | /api/resources/{resource}/{id}/belongs-to-many/{relationship}/pivot-fields/relatable/{attribute} | Options of a BelongsTo / MorphTo / Tag pivot field in the attach modal (v1.38.0, see Relation pickers among the pivot fields) |
GET | /api/resources/{resource}/{id}/belongs-to-many/{relationship}/pivot-fields/{relatedId}/relatable/{attribute} | The same, in the pivot edit modal of an attached record (v1.38.0) |
The MorphToMany panel serves the same routes under morph-to-many.
Attach payload:
{ "related_id": 5, "notes": "Optional pivot field value"}Update pivot payload:
{ "notes": "Updated notes"}Tag (BelongsToMany chip UI)
A many-to-many relationship rendered as editable tag chips with autocomplete. Best for flat, unordered tag-like associations with no pivot data.
Tag::make('tags', 'Tags') ->relatedResource('tags') ->titleAttribute('name') ->withPreview() ->preload()Use BelongsToMany instead of Tag when you need pivot fields, a full DataTable listing, or search/sort/pagination controls.
MorphTo
A polymorphic relationship. The parent model can belong to different model types via a single FK pair (_type + _id).
MorphTo::make('commentable', 'Commentable') ->types([PostResource::class, VideoResource::class]) ->nullable()Pass the relationship method name (e.g. commentable), not the FK column. MorphTo writes both commentable_type and commentable_id based on the resolved related record.
See fields.md — MorphTo for full API reference.
HasOne
A one-to-one relationship. Displays and manages a single related record via an Eloquent hasOne relationship. The related record is shown as a read-only panel on the detail page, with optional Create / Edit / Delete controls.
Detail-only by default — hidden from index and forms.
use Martis\Fields\HasOne; HasOne::make('Profile')HasOne::make('Profile', 'profile')HasOne::make('Profile', 'profile', ProfileResource::class) ->canCreate(false) ->canUpdate(false) ->canDelete(false)See fields.md § HasOne for the full API.
Note the static factory HasOne::ofMany($name, $relationship, $resourceClass)
promotes a hasMany()->latestOfMany() relation into a
HasOneOfMany field.
The card is the related record's detail view (v2.0). As in Nova, which
hides the panel when the related view policy denies the record
(nova-dusk-suite: HasOneAuthorizationTest;
Nova reads it with a detail query),
a HasOne, HasOneOfMany, HasOneThrough, MorphOne or MorphOneOfMany
card shows its record only when the user may view it. When the policy
denies the record the card is not rendered at all, as Nova drops the panel:
no heading, no Create (which would add a second record to a HasOne), no
Edit, no count. The card's endpoint answers data: null with
meta.hidden: true then (a card with no record answers data: null alone),
and its Edit and Delete answer 404.
A write names the record the card shows (v2.0). PUT and DELETE on
/api/resources/{resource}/{id}/has-one/{relationship} (and morph-one)
take the id of the record the card displays as ?relatedId=, which the card
sends. When the relationship holds another record by then (a newer
one-of-many record, a HasOne replaced, another record through a
HasOneThrough) the write answers 409 (The record changed since the card loaded; reload to see it.) and touches nothing; the card reloads and shows
the message. Without the id the write answers 422. The checks run in this
order: 404 when the user may not view the record the relationship holds
(whatever id is sent), 409 when the id names another record (before the
policy, which is the current record's: the request was for the other one),
403 when the policy denies the write, then 422 when the id is missing
(so a denied user gets the 403 either way). The
card keeps the id it showed when Delete was clicked: a reload while the
confirmation is open (the window regaining focus) does not change which
record the confirm names. Before v2.0 the write went to whatever record the
relationship held at that moment, so a Delete could remove a record the user
had not seen.
Nova's HasOne panel is the related resource's detail component
(nova-dusk-suite tests/Browser/HasOneAuthorizationTest.php).
Its delete endpoint deletes the ids it receives
(DELETE /nova-api/{resource}?resources[]={id}, sent with viaResource,
viaResourceId and viaRelationship) without comparing them with the
relationship
(laravel/nova-issues#6364).
Martis compares them and answers 409 when the record is no longer the one
the relationship holds: the record is not the one the card stands for any
more, and the user sees the current one before deciding.
A HasOne or MorphOne takes one record. Creating a second one through
the card's endpoint answers 422 (The HasOne relationship has already been filled., Nova's wording for a HasOne,
nova-dusk-suite lang;
a MorphOne answers the same sentence with its own name, which is Martis's),
also as the response's message, whether or not the user may view the
record already there, and checks again under a lock on the parent before it
writes, so two concurrent creates cannot both succeed. Only that check and
the insert run in the lock's transaction (the model's own saving /
created events fire inside it): the related resource's afterSave() and
the deferred writes of the form (a Tag, a Repeater) run after the
commit, as on every other create, so a job they dispatch finds the record.
A one-of-many card's create opens no transaction. Nova hides the
Create button once a record exists
(HasOneRelationTest).
Before v2.0 it answered 500. A one-of-many card sits on a many
relationship and takes more records. Like the resource's own detail page, the card does not apply the related
resource's indexQuery(); the one-of-many "1 of N" count and the
aggregateVia() tile, which count a list, do (see below).
Divergence from Nova: a trashed record. Nova reads a HasOne without
global scopes, so its panel shows a soft-deleted record
(nova-issues discussion #4120);
a Martis card reads it through the relationship, whose SoftDeletes scope
leaves a trashed record out, so the card shows none.
HasOneOfMany
Promotes a hasMany()->latestOfMany() (or ->ofMany(column, aggregate)) relationship so the admin shows the latest / oldest of many as if it were a plain HasOne. Visually identical to HasOne.
use Martis\Enums\AggregateFunction;use Martis\Fields\HasOne; // Two equivalent ways to declare the field:HasOne::ofMany('Latest Invoice', 'latestInvoice', InvoiceResource::class)HasOneOfMany::make('Latest Invoice', 'latestInvoice', InvoiceResource::class) ->latestByTimestamp('paid_at') // ⭐ ->aggregateVia(AggregateFunction::Sum, 'amount'); // ⭐Model side:
// App\Models\Projectpublic function latestInvoice(): HasOne{ return $this->hasOne(Invoice::class)->latestOfMany();}See fields.md § HasOneOfMany for the full API.
⭐ Martis differentials:
- "Latest of N" pill appears automatically on the detail panel next to the section heading (
1 de 12), surfacing the size of the underlying collection. It and theaggregateVia()tile count the records the related resource's index would list (itsscopes()andindexQuery(), v2.0), so a hidden record is not counted. latestByTimestamp()/oldestByTimestamp()avoid the verbose->ofMany('created_at', 'max')boilerplate.aggregateVia()surfaces a metric tile with the full collection aggregate.
On an Eloquent one-of-many relation (latestOfMany(), ofMany()), the pill and the tile cover every related row of the parent: the constraints written into that relation do not apply to them. The global scopes it removes (->withoutGlobalScope(...)) stay removed, as for its record.
HasOneThrough
Shows a single distant record reached through an intermediate model. Rendered visually like HasOne, but, as in Nova, without Create: a record cannot be created through the relationship. Edit and Delete work as on HasOne.
use Martis\Fields\HasOneThrough; HasOneThrough::make('Account Manager', 'accountManager', TeamMemberResource::class) ->throughBreadcrumb(); // ⭐Model side:
// App\Models\Projectpublic function accountManager(): HasOneThrough{ return $this->hasOneThrough( TeamMember::class, // target Client::class, // intermediate // … FK / PK hints if the defaults don't match );}See fields.md § HasOneThrough for the full API.
All HasOne methods are inherited, except that canCreate() has no effect
(canCreate(true) logs a warning naming the field, once per request).
throughBreadcrumb(bool) ⭐ adds a "through" hint next to the section heading.
The relationship has no foreign key of its own to write, so the has-one
endpoints refuse a create through it: POST /api/resources/\{resource}/\{id}/has-one/\{relationship} answers 403 (Records cannot be created through a hasOneThrough relationship.), also when the
relationship is declared with a plain HasOne (or HasOneOfMany) field.
Create the record from its own resource. PUT and DELETE on the same URL
work as on HasOne: they reach the record the relationship holds, under the
related resource's update / delete policies, and the card leaves out the
Edit or Delete a policy denies for that record. Coming from 1.x, see
Upgrading the Through fields from 1.x.
⭐ Martis differentials:
- No create through the relationship, as in Nova, and enforced: the
has-oneendpoints refuse one (403). throughBreadcrumb()hint describes the intermediate hop without a custom tooltip.
MorphOne
A polymorphic one-to-one relationship. Mirrors HasOne but for morphOne Eloquent relationships. Detail-only by default.
use Martis\Fields\MorphOne; MorphOne::make('Thumbnail')MorphOne::make('Thumbnail', 'thumbnail', ThumbnailResource::class) ->canCreate(false) ->canUpdate(false)See fields.md § MorphOne for the full API.
MorphOneOfMany
Polymorphic counterpart of HasOneOfMany. Promotes morphMany()->latestOfMany() (or ->ofMany(...)).
use Martis\Fields\MorphOne; MorphOne::ofMany('Latest Note', 'latestNote', NoteResource::class) ->latestByTimestamp() // ⭐ ->aggregateVia(AggregateFunction::Count, '*'); // ⭐Model side:
// App\Models\Clientpublic function latestNote(): MorphOne{ return $this->morphOne(Note::class, 'noteable')->latestOfMany();}Inherits every MorphOne method plus the OfMany extras (latestByTimestamp / oldestByTimestamp / aggregateVia). Same "Latest of N" pill + aggregate tile as HasOneOfMany.
HasManyThrough
Inline DataTable of many records reached through an intermediate. As in Nova, without Create: a record cannot be created through the relationship. The rows keep View, Edit, Delete, Restore and Force delete, as on HasMany.
use Martis\Fields\HasManyThrough; HasManyThrough::make('Managed Projects', 'managedProjects', ProjectResource::class) ->throughBreadcrumb() // ⭐ ->countBadge(); // ⭐ on by defaultModel side:
// App\Models\TeamMemberpublic function managedProjects(): HasManyThrough{ return $this->hasManyThrough( Project::class, Client::class, 'account_manager_id', 'client_id', 'id', 'id', );}See fields.md § HasManyThrough for the full API.
All HasMany methods are inherited, except that canCreate() has no effect
(canCreate(true) logs a warning naming the field, once per request).
Adds throughBreadcrumb(bool) ⭐ and countBadge(bool) ⭐.
The relationship has no foreign key of its own to write: a create through it
would put the parent's key in the related record's key to the intermediate
model (client_id above), filing the project under whichever client has that
id. The has-many endpoints therefore refuse a create through it: POST /api/resources/\{resource}/\{id}/has-many/\{relationship} answers 403 (Records cannot be created through a hasManyThrough relationship.), also when the
relationship is declared with a plain HasMany field. Create the projects from
their own resource. PUT and DELETE …/has-many/{relationship}/{relatedId}
work as on HasMany: they reach a project the relationship holds (404 for any
other), under the related resource's update / delete policies, and a row
leaves out the actions a policy denies for its record.
⭐ Martis differentials:
- No create through the relationship, as in Nova, and enforced: the
has-manyendpoints refuse one (403). countBadgebrings the count affordance to Through fields (in addition toshowRelationCountonHasMany).
Upgrading the Through fields from 1.x
Martis 2.0 aligns HasOneThrough and HasManyThrough with Nova. In 1.x:
- the
has-manyendpoints took a create through ahasManyThroughrelationship and filed the new record under whichever intermediate had the parent's id, and so did thehas-oneendpoints when a plainHasOnefield declared ahasOneThroughone; canCreate()brought the Create button back on a Through panel, and a plainHasMany/HasOnefield declaring a Through relationship showed it by default;- the Through panels hid Edit and Delete by default (
canUpdate/canDeletestarted asfalse), and thehas-oneendpoints refused an update or a delete through aHasOneThroughfield (403). TheHasManyThroughpanel already showed Restore and Force delete on trashed rows, for every record.
In 2.0 a create through a Through relationship answers 403 and the panels never
offer Create, while Edit and Delete show as on HasOne / HasMany. Restore and
Force delete on HasManyThrough do not change. Every row and card action now
also follows the related resource's policy for its record: an action the policy
denies for a record is left out.
What to do:
- The upgrade rebuilds the schema cache, whose keys carry the installed
Martis version. On a path repository, whose version does not change, clear
it (
php artisan martis:cache:clear schema): until it is rebuilt a panel keeps the actions it offered in 1.x. - To keep the 1.x panel, hide Edit and Delete on the field:
->canUpdate(false)->canDelete(false). Since v2.4.0 those setters refuse the writes on the endpoints too (403); before, the endpoints followed the policies only, so denyupdate/deletethere. - Create the related records from their own resource (its create page, or
POST /api/resources/{related}). An API client that created them through…/has-many/{relationship}or…/has-one/{relationship}of a Through relationship now gets a 403 there. - Declare a Through relationship with
HasOneThrough/HasManyThrough. A plainHasMany/HasOnefield on one still shows Create, and its request now answers 403. - Drop
canCreate()from Through fields. It no longer does anything; it stays callable, so a resource that still calls it keeps loading, andcanCreate(true)logs a warning naming the field, once per request (Log::warning, on the default log channel). - If a panel offered Create on a Through relationship in your app, check the
records created from it: the create wrote the parent's id into the
relationship's second key (for
TeamMember::managedProjects()above, the project'sclient_id).
MorphMany
A polymorphic one-to-many relationship. Renders as a DataTable panel on the detail page with full inline CRUD, similar to HasMany but for morphMany Eloquent relationships.
Detail-only by default.
use Martis\Fields\MorphMany; MorphMany::make('Comments', 'comments', CommentResource::class) ->collapsable() ->collapsedByDefault() ->perPage(10) ->canCreate(false)See fields.md § MorphMany for the full API.
MorphToMany
A polymorphic many-to-many relationship. Behaves like BelongsToMany (DataTable UI, attach/detach, pivot fields, pivot actions, search) but for morphToMany Eloquent relationships. Pivot actions come from the field's ->actions() and from the resource actions flagged ->pivotAction(), as on BelongsToMany (see Actions → Pivot Actions); up to v1.37.3 the panel asked for pivot action endpoints that did not exist, so none showed.
On the detail page and the update form; never on a create form, like BelongsToMany above.
use Martis\Fields\MorphToMany; MorphToMany::make('Tags', 'tags', TagResource::class) ->titleAttribute('name') ->searchable() ->collapsable() ->fields(fn () => [ Text::make('notes', 'Notes')->nullable(), ])See fields.md § MorphToMany for the full API. The relation pickers among its pivot fields ask the panel, under .../morph-to-many/{relationship}/pivot-fields/..., like a BelongsToMany's (see Relation pickers among the pivot fields).
Choosing the Right Field
| Use case | Field |
|---|---|
| FK stored on this model | BelongsTo |
| FK stored on related model, single record | HasOne |
| FK stored on related model, many records | HasMany |
| Pivot table, DataTable UI, pivot fields | BelongsToMany |
| Pivot table, chip/autocomplete UI, no pivot data | Tag |
| Polymorphic parent (belongs to one of many types) | MorphTo |
| Polymorphic has-one (single child across types) | MorphOne |
| Polymorphic has-many (many children across types) | MorphMany |
| Polymorphic many-to-many with pivot | MorphToMany |
Hardening — guaranteed behaviour matrix
The hardening pass codified the contract every relationship surface guarantees. Each row below has a feature test in tests/Feature/. Use this as your spec when building or migrating a relationship-heavy resource.
Per-type controller behaviour
| Behaviour | HasMany | HasOne | BelongsToMany | MorphMany | MorphOne | MorphToMany |
|---|---|---|---|---|---|---|
| Listing scoped to parent | ✅ | n/a (single) | ✅ | ✅ | n/a (single) | ✅ |
| Cross-type isolation (same ID, different morph type) | n/a | n/a | n/a | ✅ | ✅ | ✅ |
| Pagination + per-page | ✅ | n/a | ✅ | ✅ | n/a | ✅ |
| Search (against searchable fields on the related resource) | ✅ | n/a | ✅ | ✅ | n/a | ✅ |
| Sort (asc / desc on sortable fields) | ✅ | n/a | ✅ | ✅ | n/a | ✅ |
| Inline create / store with FK or morph keys auto-filled | ✅ | ✅ | ✅ (attach) | ✅ | ✅ | ✅ (attach) |
| Update preserves FK / morph keys | ✅ | ✅ | n/a | ✅ | ✅ | n/a |
| Delete / detach scoped — never touches another parent's records | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 404 on unknown parent / record / relationship | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 422 on missing required input | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Field rules run as on the resource endpoint (rule objects, ValidationRules, closures, creationRules() / updateRules()) | ✅ | ✅ | ✅ (pivot fields) | ✅ | ✅ | ✅ (pivot fields) |
immutable() fields written on create, skipped on update, as on the resource endpoint | ✅ | ✅ | ✅ (pivot fields) | ✅ | ✅ | ✅ (pivot fields) |
readonly() pivot fields never written from the request (the attach stores their default()) | n/a | n/a | ✅ | n/a | n/a | ✅ |
canSee() pivot fields left out of the schema, _pivot and the validation, never written from the request | n/a | n/a | ✅ | n/a | n/a | ✅ |
canSeeForModel() fields left out of the records sent and of the validation, never written from the request (decided on the stored record, the new model on a create, the pivot row for pivot fields) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
404 on every endpoint of a relationship field canSeeForModel() hides for the parent record | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Pivot values written through each field's fill() (fillUsing(), computed, structured fields, custom pivot casts) | n/a | n/a | ✅ | n/a | n/a | ✅ |
| Pivot data round-trip on attach + index + update | n/a | n/a | ✅ | n/a | n/a | ✅ |
Pivot actions listed, described and run per panel; {relationship} resolves only to a declared field of the route's type | n/a | n/a | ✅ | n/a | n/a | ✅ |
Authorization — authorizedToCreate / view / detach respected | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
attachAny{Model} gates the list of records to attach, the attach and the attach modal's pivot pickers; attach{Model} then decides per record | n/a | n/a | ✅ | n/a | n/a | ✅ |
Parent record found through the resource's scopes() + indexQuery() (else by key, the view policy deciding; see How a panel finds its parent record) | policy | policy | ✅ | policy | policy | pivot routes only |
Pivot data API (BelongsToMany & MorphToMany)
Pivot fields declared via ->fields(fn () => [Number::make('weight'), ...]) round-trip through three surfaces:
| Surface | Body shape | Notes |
|---|---|---|
| Index response | data[*]._pivot | Pivot keys are merged into _pivot on each related row. Underscore prefix is intentional: it keeps them visually distinct from real columns. A pivot field the user cannot see is left out, and a pivot Repeater's rows are read like a record's (v1.38.0+). A pivot field hidden for the pivot row (canSeeForModel()) is left out and listed under _pivot._hidden (v1.38.0+). |
| Attach | flat keys at top level: { related_id: X, weight: 12 } | Pivot fields read directly from request input via extractPivotData(). |
Update pivot (PUT .../{relatedId}/pivot) | flat keys at top level: { weight: 99 } | Same shape as attach — no nested pivot key. |
Multi-relations to the same target model
A resource can declare multiple BelongsTo / HasMany / BelongsToMany fields whose related model is the same class. The hardening pass guarantees they stay isolated:
public function fields(Request $request): array{ return [ BelongsTo::make('manager', 'Manager')->relatedResource('users'), BelongsTo::make('lead', 'Lead')->relatedResource('users'), ];}- Each field has its own
attribute()(the foreign key —manager_idandlead_id). /relatable/{attribute}resolves the right field independently.- The detail payload serializes both relations with their own values.
relatable{PluralModelName}(Request, Builder, ?FieldContract)accepts an optional third parameter — when present, the resolver passes the active field instance so the hook can branch per-field:
public static function relatableUsers(Request $request, Builder $query, ?FieldContract $field = null): Builder{ return match ($field?->attribute()) { 'manager_id' => $query->where('role', 'manager'), 'lead_id' => $query->where('role', 'lead'), default => $query, };}Relation fields declared on one form only
The picker endpoint of BelongsTo, MorphTo and Tag (GET /api/resources/{resource}/{id}/relatable/{attribute}) looks the field up on the form the picker renders in, so a relation field that a resource declares only in fieldsForCreate() or fieldsForUpdate() lists its options like one declared in fields():
public function fields(Request $request): array{ return [Text::make('name')];} public function fieldsForCreate(Request $request): array{ return [ Text::make('name'), Tag::make('tags', 'Tags')->relatedResource('tags')->titleAttribute('name'), ];}The {id} segment of the URL decides which form is read, and fields() comes last:
{id}names a record of the resource the user may update (the edit form):fieldsForUpdate(), on the resource bound to that record, so arelatableQueryUsing()closure on the field can read it through$this->model.{id}is_(a create form), names no record, or names a record the user may not update:fieldsForCreate(), thenfieldsForInlineCreate()(the inline-create modal), on a resource bound to no record.fields(), so a picker that onlyfields()declares keeps working on every form.
The first declaration found wins: when fields() and fieldsForUpdate() declare the same attribute differently, the edit form's picker uses the fieldsForUpdate() one (its relatableQueryUsing(), withoutTrashed(), related resource). Section / Panel / TabGroup containers are searched, and only a BelongsTo, MorphTo or Tag under the attribute counts, so a read-only Text that reuses the attribute on a form does not hide the picker declared in fields().
Authorisation: viewAny on the resource and on the related resource, then the scoping below. The record {id} names is only bound when the resource's authorizedToUpdate() passes for it (the same check the edit form itself needs); any other record is answered from the create form, exactly like an id that names no record, so nothing a closure derives from that record reaches the answer and the answer does not reveal whether the record exists.
Before v1.38.0 any record
{id}named was bound after theviewAnycheck alone: a user who could not view or edit a record read the options itsfieldsForUpdate()closures derived from it.
Before v1.38.0 the endpoint searched
fields()only: a picker declared on a form alone answeredField 'x' not found.(404) and opened with no options.
The pickers fill {id} from the form they render in: the record under edit on an update form, _ on a create form. A create form nested in another resource's page sends _ as well, so the inline-create modal and a create drawer opened from an edit or detail page read the create forms of their own resource. A record id the host passes explicitly (recordId, as a Tool form bound to a record does) always wins; otherwise the record id in the page URL is only used outside a create form, and only for the page's own resource.
The pickers of an Action modal ask the Action instead: GET /api/resources/{resource}/actions/{action}/relatable/{attribute} looks the field up in the Action's fields(), so its related resource, relatableQueryUsing() and withoutTrashed() apply, and the resource the Action runs on is the source of the relatable{PluralModelName}() hook. It answers 403 without viewAny on that resource or when the Action's canSee() denies, and 404 for an attribute the Action does not declare as a BelongsTo, MorphTo or Tag. The modal of a pivot action asks its panel the same way, under /{resource}/{id}/{belongs-to-many|morph-to-many}/{relationship}/actions/{action}/relatable/{attribute}, with the gates of the panel's pivot actions (see Pivot Actions) and the parent resource as the source. See Actions → Relation fields.
Before v1.38.0 the pickers of an Action modal asked the page's resource: an attribute only the Action declares answered 404 with an empty picker, and one the resource also declares listed the resource's options (its related resource and scope) instead of the Action's.
The pickers among a BelongsToMany / MorphToMany panel's pivot fields ask the panel the same way, under .../{relationship}/pivot-fields/relatable/{attribute} (the attach modal) and .../{relationship}/pivot-fields/{relatedId}/relatable/{attribute} (the pivot edit modal), gated like the attach and the pivot update (see Relation pickers among the pivot fields).
A picker in a Repeater row adds the row to any of these requests: ?repeater={attribute}&repeatable={type} names the Repeater and the row type (Repeatable::shortName()), and the field is read from that row type's fields() on the form (or Action, or pivot fields) the Repeater belongs to, under the same gates. See Repeater → Relation pickers and remote selects in rows.
The Slug collision check reads the forms in the same order (the update form when its id names a record the user may update, and only that record is left out of the uniqueness probe). The dependsOn sync and the server-side Select search take the form from their context parameter and search only that form (create includes fieldsForInlineCreate()), never fields(), so a field that is not on the form cannot be probed.
Relatable scoping precedence
When a picker list is computed, and when a write is checked against it (see Writes follow the pickers), scopes apply in this order, on every picker that targets the resource: the BelongsTo dropdown (/relatable/{field}), the Action modal pickers (/actions/{action}/relatable/{field}, with the resource the Action runs on as the source, and .../{relationship}/actions/{action}/relatable/{field} for a pivot action, with the parent resource), the pivot field pickers (.../{relationship}/pivot-fields/relatable/{field} and .../pivot-fields/{relatedId}/relatable/{field}, with the parent resource), the pickers of a Repeater row (any of these with ?repeater=&repeatable=), the context-free relatable form (/_/_/relatable/{field}?related_resource=), and the BelongsToMany / MorphToMany attach picker (.../attachable). The layers:
relatableQueryon the target resource — the generic fence the target declares for itself. It always runs.relatable{PluralModelName}on the source resource (specific override, gets passed the field) — narrows the already-fenced query for that source's relationships.- Field-level
relatableQueryUsing(fn ($request, $query) => ...)(BelongsTo / BelongsToMany / MorphToMany). - Field-level
withoutTrashed()(BelongsTo / BelongsToMany when the model usesSoftDeletes).
Each layer is composable — declaring a scope at one layer does not disable the others, and a lower layer can only ever narrow the result of the layers above it. Layers 1 to 3 run as Eloquent runs a local scope (v2.0.1): what each adds is one group, so an orWhere() in a source hook or a field closure (where('active', true)->orWhere('vip', true)) cannot OR the target's fence away, and one in relatableQuery() cannot keep the picker's search term from narrowing it. Before v2.0.1 they were appended ungrouped, and such an orWhere() listed another tenant's records in the picker. In particular a source-side relatable{PluralModelName}() never replaces the target's relatableQuery(): a resource that fences itself (a tenant or ownership predicate on a model that cannot carry a global scope, such as the User model tenancy is resolved from) stays fenced no matter which resource offers the picker or which override that resource declares. This is a deliberate divergence from Nova, where the source override is an either/or replacement.
Before v1.34.0 the attach picker never called
relatableQuery()(only the field closure) andrelatable{PluralModelName}()replacedrelatableQuery(). A consumer that only needs the index fence on its pickers declares it once inrelatableQuery(); it no longer has to repeat it on everyBelongsToMany/MorphToManyfield targeting the resource.
Writes follow the pickers
A relationship write must name a record its picker would list, as Nova's Relatable and RelatableAttachment rules require: an id outside the relatable query answers 422 with an error on the field (martis::validation.relatable, "This :attribute may not be associated with this resource."; relatable_attachment for an attach, in en, pt_PT and pt_BR), and nothing is written. The check runs the query the picker runs, with the layers above (the target's relatableQuery(), the source's relatable{PluralModelName}(), the field's relatableQueryUsing() and withoutTrashed()), on every write:
| Write | Field | Source resource |
|---|---|---|
Create, update, inline create (POST /api/resources/{resource}, PUT .../{id}, POST .../inline-create) | BelongsTo, MorphTo, Tag | The resource |
Inline create and update of a HasMany, HasOne, MorphMany, MorphOne panel | BelongsTo, MorphTo, Tag of the related resource | The related resource |
Attach (one record or a batch) and pivot update of a BelongsToMany / MorphToMany panel | The attached record (related_id, related_ids, the {relatedId} of the pivot update) and the pivot fields' BelongsTo, MorphTo, Tag | The parent resource |
| An Action or pivot action run | The Action's BelongsTo, MorphTo, Tag | The resource the Action runs on (the parent resource for a pivot action) |
Any of these with a Repeater | The BelongsTo, MorphTo, Tag of its rows (errors under {repeater}.{index}.fields.{attribute}) | The resource of the write |
As Nova's rule, the check runs on the value the request sends, an update that sends the stored value back included: a record whose target has left the query since answers 422 until the target changes (or the hook is widened).
On top of the query:
- the user must be allowed to list the related resource (
viewAny), as the picker requires; - a
BelongsTo/MorphToneeds theadd{SourceModel}ability of the related record's policy (Nova'sauthorizedToAdd(); allowed when the policy does not define it): a task's owner is refused whenUserPolicy::addTask($user, $owner)returnsfalse, the ability theHasManypanel on the user's page checks before adding a task; - a
BelongsTo/MorphTofails when the picked record's inverseHasOne/MorphOnealready holds another record (Nova'srelationshipIsFull()): a profile cannot take a user whoseHasOneprofile is filled. The inverse is the related resource'sHasOne/MorphOnefield that points back at the resource of the write (not a one-of-many or Through field), or the oneinverse()names. As in Nova, an update passes when the record keeps its target, and when it has no target yet (Nova's rule skips the check then). Only the record of the write is checked, not a pivot row, a Repeater row or an Action; - a
Tagneeds the source resource'sattachAny{Model}andattach{Model}abilities for every record it adds, anddetach{Model}for every record it removes (anullvalue removes them all), as the attach and detach endpoints; a removal the policy refuses answers 422 withmartis::validation.detachable. Nova'sTagfield checks none of them.
What passes without a check:
- an empty value (the field's own
required()/nullable()decide), a readonly field, and aMorphTovalue that names no type oftypes()(it writes nothing); - the parent of a
HasMany/HasOne/MorphMany/MorphOneinline create: the store writes the parent whatever the form sends for the inverse field, so that field is not checked against its picker.
Soft-deleted records. A soft-deleted related record fails, as the picker never lists it, unless the request opts in: it sends {attribute}_trashed=true next to the value (Nova's opt-in, for example owner_id_trashed=true), or a BelongsTo / MorphTo value map with trashed: true. The record's value carries trashed: true when its target is soft-deleted, so an edit form that sends it back keeps the target, as Nova's form turns on "With Trashed" for a trashed target. Either way the related resource must let the user see trashed records (canViewTrashed()) and the field must not be withoutTrashed(). An attach never takes a trashed record.
The attach and the form draft. A relatableQueryUsing() closure with a third argument receives the parent form draft the request sends in ?form[attribute]=value. The attach modal of a BelongsToMany or MorphToMany panel sends the draft its picker used (the values of the fields its dependsOn([...]) names); an attach or a pivot update that sends none gives the closure an empty array, as the picker does.
An id that names no record fails the attach exactly like a record the picker does not list (422 on related_id, v2.11.1, as Nova's RelatableAttachment rule), and both fail before attach{Model} is asked, so the attach cannot tell another tenant's records apart from missing ids either. Only a record the picker lists reaches the policy (403 when it refuses). Up to v2.11.0 a missing id answered 404 and a policy denial came before the picker check.
A batch attach checks every record first: one record outside the picker, or an id that names no record, fails the whole batch (422 on related_ids) before anything is attached or any policy is asked. Records that attach{Model} refuses, or that are already attached, keep their v2.0 handling (listed in meta.errors, skipped). Up to v2.11.0 a missing id was listed in meta.errors and skipped.
The resource a BelongsTo is checked against (v2.4.0+). The check needs the related resource, which the field names with relatedResource():
relatedResource('users')names it. A URI key that no registered resource has throws anInvalidArgumentExceptionthat names the field and the key, when a non-empty value is written: a typo in the key used to let every id through, and now fails loudly.- No
relatedResource(): the resource is the one registered for the model of the relationship (BelongsTo::make('team')readsTask::team(), takes its related model and finds the resource registered for it), as Nova finds aBelongsToresource when none is given. A headless resource over that model does not count when a routable one exists (v2.11.0; before, adding one made the field refuse every value). The full check above runs against it: itsrelatableQuery(), the source'srelatable{PluralModelName}(), the field'srelatableQueryUsing()andwithoutTrashed(),viewAny, theadd{SourceModel}policy and the full inverse. - A pivot field of a
BelongsToMany/MorphToManypanel reads its relationship from the pivot model (->using(Assignment::class)with anapprover()method on it), not from the parent record the panel belongs to: a same-named relation of the parent is never the one checked (v2.4.0; before, the parent's relation, if any, decided which resource the write was checked against). A plain pivot has no relationship methods, so declarerelatedResource()on such a field. - Neither names exactly one resource: the relationship is not defined on the record (an Action field or a Repeater row whose host model has no such relationship), the related model has no registered resource, or several routable resources are registered for it (or several headless ones and no routable one). The value is refused with 422 (
martis::validation.relatable_unresolved, "The :attribute has no related resource to check the selected record against. Declare relatedResource() on the field.") instead of being written unchecked. DeclarerelatedResource()on the field.
An id that is not an int or a string (v2.4.0+). A BelongsTo or MorphTo value names its id as a raw value or an {id: ...} map. A JSON boolean, a non-integer number, a list or a nested map is not an id (a boolean true would be stored as key 1), so the same reading refuses it in both places: the Relatable rule answers 422 (martis::validation.relatable) and fill() writes nothing for it. The empty string and null still clear a nullable field.
A BelongsTo map reaches the rules as its id (v2.10.0). A create opened from a relationship panel pre-fills the BelongsTo back to the parent, and the form holds that value as the { id, title } map. The create page, the create drawer and the inline create modal send the id, as an update always did, and the write endpoints also reduce an { id: ..., ... } map to its id before the field's own rules and the fill run (the resource's store and update, the panels' store and update, the pivot rows and an Action's fields), for any client. A consumer rule such as Rule::exists('contacts', 'id') therefore receives a scalar: an array made exists a multi-value whereIn, a 500 on PostgreSQL with a bigint key and a false 422 elsewhere. A map whose id is malformed (see above) is left as it is, so the Relatable rule still refuses it on the resource's store and update, the pivot rows and an Action's fields (the parent key of a panel store is overwritten by the controller, not refused), and so is a map that carries trashed: true, the edit form's opt-in for a soft-deleted target, which the Relatable rule reads from the map. A map that names no id ({ title: 'Ana' }) now reads as an empty value, so required refuses it. A fillUsing() callback receives the id instead of the map. A BelongsTo inside a Repeater row is not reduced: the Relatable check still reads the map, but a consumer rule on a row field receives the map.
The forms decide by the field's type, not by the shape of the value: only a belongs_to value is reduced, so a KeyValue or a JSON Code field whose map has id, title or url keys of its own is sent as it is. For a soft-deleted target ({ id, title, trashed: true }) the update form also sends <attribute>_trashed: true, Nova's opt-in, so saving any field of such a record no longer answers 422 on the BelongsTo.
Before v2.4.0 a field with no related resource, or one that named an unregistered key, wrote any submitted id with no check: a crafted request could point the foreign key at another tenant's record. The picker still needs relatedResource() (the frontend renders a numeric id input without it, and the picker endpoint reads the key from the field), so declare it on every BelongsTo.
A Tag is checked only when it names its related resource with relatedResource(): without one the ids it syncs are written as the request sends them.
In a Repeater row the value is stored in the row, not as a relationship of the record: the query, viewAny and add{Model} apply, the full inverse and the Tag attach / detach abilities do not.
Polymorphic cross-type isolation
For every morph relation (MorphMany, MorphOne, MorphToMany), the controllers scope reads, writes, and deletes by {morph_type, morph_id} together — never by morph_id alone. Concrete guarantees:
- Listing comments on a
Postnever includes comments belonging to aVideowith the same numeric id. - Updating a comment via
/morph-many/comments/{id}from the wrong parent type returns 404, the comment is not modified. - Detaching a tag via
/morph-to-many/tags/{id}/detachonly removes attachments wheretaggable_typematches the parent class. A tag attached only to another type answers 404, like one that does not exist (v2.11.1). MorphOneController::destroynever deletes a sibling morph type's relation that happens to share the sameimageable_id.
Detach idempotency
DELETE /belongs-to-many/{relatedId}/detach and DELETE /morph-to-many/{relatedId}/detach are safe to retry: the first call detaches, a repeat answers 404 and changes nothing, never 500. Useful in retry-prone surfaces (mass detach, optimistic UI rollback).
Only an attached record is detached
The detach and the pivot update (PUT .../{relatedId}/pivot) of both panels look the record up through the relation (v2.11.1): a record that exists but is not attached to the parent answers 404 with exactly the body of an id that does not exist (Related record not found.), before any policy is asked. So neither route tells another tenant's records apart from missing ids, even when the related model has no tenant scope and the related resource confines its users only through relatableQuery() / indexQuery(). The pivot actions already resolved their records through the relation. An id the key column cannot hold (abc for an integer key) answers the same 404 without reaching the database, where PostgreSQL would reject it. The attach answers a missing id like a record its picker hides (see Writes follow the pickers).
The attach, the pivot update and the detach write and find the pivot row through the relationship's relatedKey (the fifth argument of belongsToMany(), the seventh of morphToMany()), as Eloquent does; up to v2.11.0 they used the related record's primary key, so a relationship whose pivot stores another column (a code, a slug) attached the primary key into it, and the detach and the pivot update found no row. Up to v2.11.0 both routes looked the record up on the related model alone: the detach of a record that was not attached answered 200 and changed nothing, and the pivot update answered 200 (or 422 when the attach picker did not list it), while a missing id answered 404.
Error matrix
| Status | When |
|---|---|
200 / 201 / 204 | Success. |
403 | Policy / authorizedToCreate / authorizedToView denial. |
404 | Unknown source resource, unknown parent record, unknown relationship name, OR a related id that exists in the DB but does not belong to this morph parent, OR (detach and pivot update, v2.11.1) a related record that is not attached to the parent. |
422 | Validation failure (missing required field, missing related_id, invalid pivot data), or a related id the field's picker does not list, or (attach, v2.11.1) a related id that names no record (Writes follow the pickers). |
500 | Bug — please file an issue. |
Test coverage
Per-type feature tests:
tests/Feature/HasManyControllerTest.php(22)tests/Feature/HasOneControllerTest.php(11)tests/Feature/BelongsToManyControllerTest.php(25)tests/Feature/MorphManyControllerTest.php(17)tests/Feature/MorphOneControllerTest.php(13)tests/Feature/MorphToManyControllerTest.php(18)tests/Feature/PivotActionControllerTest.php(27) — pivot actions onBelongsToManyandMorphToManypanels: listing, fields and run, the field'sactions()against the resource'spivotAction(),{relationship}resolved only to a declared field of the route's type, and the view /canSee()/canRun()gates.tests/Feature/RelationshipsHardeningTest.php(7) — multi-relation isolation,relatableQueryUsing,relatable{PluralModelName}, detach idempotency, search.tests/Feature/ReflexiveBelongsToManyTest.php(3) — a model related to itself: the parent record is listed among its attachable records, and arelatableQueryUsing()excluding the route id leaves it out of the list and refuses attaching it (422).tests/Feature/RelationshipFieldRulesTest.php(61) — every kind of field rule and the context rules on each write endpoint, next to the resource endpoint they match.tests/Feature/RelationshipImmutableFieldsTest.php(10) —immutable()on each inline create and update, next to the resource endpoint they match.tests/Feature/PivotReadonlyImmutableFieldsTest.php(24) —readonly()andimmutable()pivot fields on the attach (single and batch) and the pivot update, next to the resource endpoint they match, plus a pivot update with nothing to write.tests/Feature/PivotFieldFillTest.php(7) — pivot values written through each field'sfill()on the attach (single and batch) and the pivot update of both panels: afillUsing()callback, aMultiSelect, a computed field and aBoolean, plus a custom pivot class that casts a structured field once.tests/Feature/FormRecordAuthorizationTest.php(4) — the relatable options and the Slug check bind the update form of a record only whenauthorizedToUpdate()passes for it; a record the user may not update answers like a missing one (no option derived from it, no reserved value of its update form) and is not left out of the slug uniqueness probe.tests/Feature/FormFieldLookupTest.php(19) —BelongsTo,MorphToandTagpickers declared only infieldsForCreate()/fieldsForUpdate()/fieldsForInlineCreate(), the form declaration winning overfields(), the record bound to the update form, thefields()fallback and theviewAnygate, plus the Slug check,dependsOnsync andSelectsearch on a form-only field.tests/Feature/ActionRelatableEndpointTest.php(13) —BelongsTo,MorphToandTagpickers of an Action modal: the Action's declaration (related resource,relatableQueryUsing()) over the resource's, the relatable hooks, search, 404 for what the Action does not declare, and theviewAny/canSee()gates.tests/Feature/PivotActionRelatableEndpointTest.php(12) — the same pickers in a pivot action modal, onBelongsToManyandMorphToManypanels: field actions and resourcepivotAction()ones, the parent resource's relatable hooks, and 404 for an action the panel does not offer, an undeclared attribute or relationship, or a missing parent.tests/Feature/PivotFieldRelatableEndpointTest.php(40): the same pickers among the pivot fields, in the attach modal and the pivot edit modal of both panels: the pivot field's declaration andrelatableQueryUsing(), the parent resource's relatable hooks, search, a Repeater row among the pivot fields, 404 for an undeclared attribute or relationship, a missing parent and a related record the relationship does not attach, and theviewAny/view/attachAny{Model}/updatePivot{Model}gates.tests/Feature/AttachAnyGateTest.php(8):attachAny{Model}on both panels, refusing the attach (one record or several) and the list of records to attach while leaving the detach and the pivot update alone, andattach{Model}still deciding per record when it allows.tests/Feature/RepeaterRowFieldLookupTest.php(21): pickers and a remoteSelectdeclared in a Repeater's row types, read from the row the request names (repeater+repeatable) on the create and update forms, an Action's fields and a Tool's fields: two row types declaring the same attribute, search, the relatable hooks, 404 for an unknown Repeater, row type or attribute, and theviewAnygates.tests/Feature/ModelVisibilityWriteTest.php(14): a fieldcanSeeForModel()hides for the record is neither validated nor written by the resource update, create and inline create, nor by the inline create and update of eachHasMany/HasOne/MorphMany/MorphOnepanel (a create decides on the new model),canSeeUsingPolicy()included.tests/Feature/ModelVisibilityReadTest.php(15): the same fields left out of each panel's records and inline update response, of the lens rows and of the peek card, and 404 on every endpoint of a relationship field hidden for the parent record.tests/Feature/PivotFieldModelVisibilityTest.php(14):canSeeForModel()on pivot fields, decided on the pivot row (a new row on attach and in the attachable list'shiddenPivotFields, the attached row on pivot update, in_pivotand in the pivot edit modal's pickers), and on aBelongsToMany/MorphToManyfield hidden for the parent record.tests/Feature/HiddenFieldEndpointsTest.php(14): the pickers of a form, an Action, a pivot action and the pivot fields, the remoteSelectsearch of a resource and a Tool, the Slug check and thedependsOnsync answer for a field the user cannot see (a Repeater row field and a hidden Repeater included) exactly as for an undeclared one.tests/Feature/RelatableWritesTest.php(43): every write checked against its picker: the resource create, update and inline create (BelongsTo,MorphTo,Tag), aHasManyinline create (and its inverse field left alone), the attach, batch attach and pivot update of both panels, pivot fields, Action fields and Repeater rows; the stored value re-checked, the trashed opt-ins, a full inverseHasOneandinverse(), theviewAny,add{Model},attach{Model}anddetach{Model}policies, the attach's form draft, the multipart value map, the translated message, and the detach and pivot update of a record that is not attached (404, v2.11.1), and the attach of a missing id (422, before the policy).tests/Feature/ActionFieldVisibilityTest.php(9): the fields of a resource action and a pivot action the request cannot set (hidden, readonly, computed, and a Repeater's rows) left out of the modal or the validation, andhandle()receiving theirdefault()(the queued job too).
RelationshipQueryResolver
Internal resolver that powers the per-relationship relatable* overrides. Most consumers never touch it directly; it's documented here because it shows up in stack traces and is part of the public contract for advanced overrides.
Resolution chain
When a BelongsTo dropdown, a context-free relatable form or a BelongsToMany / MorphToMany attach picker needs its candidates, the resolver applies the resource-level query hooks, composed in this order:
relatableQuery(Request $request, Builder $query): Builder— the generic fence on the target resource (the related resource itself). Always applied.relatable{PluralModelName}(Request $request, Builder $query [, FieldContract $field]): Builder— pluralized model basename of the related resource, looked up on the source resource. Example: aBelongsTo::make('Author', 'author', UserResource::class)looks forrelatableUsers()on the resource callingBelongsTo::make. It narrows the result of (1); it does not replace it.
The dynamic method name uses Str::plural(class_basename($model)). The third parameter is optional — declare it if you need to know which field is asking (useful when one resource exposes multiple BelongsTo to the same target).
Override-side hook
For overrides on a BelongsTo field, prefer:
BelongsTo::make('Author', 'author', UserResource::class) ->relatableQueryUsing(fn (Request $request, Builder $query) => $query->where('is_active', true) );relatableQueryUsing() runs alongside the resolver and stacks on top of both resource-level methods. See tests/Feature/RelationshipsHardeningTest.php and tests/Feature/PickersHonourTargetRelatableQueryTest.php for the full priority matrix.