Documentation menu
HomeDocsCore ConceptsRelationships

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:

FieldEloquent Relationship
BelongsTobelongsTo()
HasOnehasOne()
HasOneOfManyhasMany()->latestOfMany() / ofMany(...)
HasOneThroughhasOneThrough()
HasManyhasMany()
HasManyThroughhasManyThrough()
BelongsToManybelongsToMany()
TagbelongsToMany() (chip UI)
MorphTomorphTo()
MorphOnemorphOne()
MorphOneOfManymorphMany()->latestOfMany() / ofMany(...)
MorphManymorphMany()
MorphToManymorphToMany()

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).

SetterHides
->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 exposed

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 own canSee() / canSeeForModel() hides keeps answering 404;
  • 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:

  1. Heading row — related-resource icon (from ResourceIcon), the field title, and a count badge with the total number of related rows. When collapsable() is on, the heading also carries the caret toggle.
  2. 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).
  3. DataTable — the list itself (see columns below).
  4. 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 widthLayout
>= 48remSingle row: heading on the left, [search + create + extras + per-page + trashed] on the right.
< 48remTwo rows: row 1 = heading + search + create; row 2 = per-page on the left, soft-delete dropdown on the right.

Columns (rendered in order)

  1. Selection checkbox — only when selectable is passed (used by BelongsToMany / MorphToMany for bulk pivot actions).
  2. "Archived" chip — only rendered when at least one row in the current page has a non-null deleted_at. Pure visual tag, no action.
  3. indexFields — resolved from the related resource's fieldsForIndex(). Rendered through FieldDisplay.
  4. pivotFields — BelongsToMany and MorphToMany only. Each pivot column reads its value from row._pivot.{attribute}.
  5. Actions column — View / Edit / Delete, plus Restore and Force-delete on trashed rows, plus any rowActionsExtras(row) the consumer returns, plus (on HasMany / 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 relations canAttach(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

SlotTypePurpose
pivotFieldsFieldDefinition[]Extra columns pulled from row._pivot.*. BelongsToMany/MorphToMany pipe their pivot fields in here.
selectable + selectedRows + onSelectionChangeboolean + ResourceRecord[] + callbackControlled multi-select. When selectable is on the shell renders a checkbox column and reports the selection upward.
toolbarExtrasReactNode | (ctx: { selectedRows }) => ReactNodeRendered 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) => ReactNodePer-row extras, appended to the action icons.
createUrl / editUrl / viewUrl / deleteUrlURL buildersEndpoint/route overrides so the same shell powers HasMany (inline CRUD) and BelongsToMany (attach/detach) transparently.

Field ↔ slot usage matrix

FieldUsesWhy
HasMany / HasManyThrough(shell defaults only)Plain inline CRUD; no pivot, no multi-select.
MorphMany(shell defaults only)Same as HasMany but polymorphic.
BelongsToManypivotFields + selectable + toolbarExtras(ctx) + rowActionsExtrasPivot columns + bulk pivot actions + Attach button + per-row Detach/edit-pivot.
MorphToManypivotFields + selectable + toolbarExtras(ctx) + rowActionsExtrasSame as BelongsToMany, polymorphic pivot.
HasOne / MorphOne / *OfMany / HasOneThroughn/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 rendersThe 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 cardThe 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:

EndpointsThe parent is found
The BelongsToMany panel: its list, attachable list, attach, detach and pivot updateThrough 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 fieldsThe same way.
The HasMany, HasOne, MorphMany and MorphOne panels, and the list, attachable list, attach, detach and pivot update of the MorphToMany panelBy 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 Boolean stores a boolean, a BelongsTo its foreign key column, a Password its 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 its default() 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 _pivot and 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 whose pivotParent is 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 _pivot lists 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 its default());
  • a pivot Repeater writes 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's default(), 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 (the attachAny{Model} policy ability), as the list of records to attach and the attach itself do: no record is picked yet, and the attach then checks attach{Model} for each one;
  • the pivot edit modal needs authorizedToUpdatePivot() for that record (updatePivot{Model}, falling back to update), 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 true

modalSize() 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.

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

MethodPathDescription
GET/api/resources/{resource}/{id}/belongs-to-many/{relationship}List attached records
GET/api/resources/{resource}/{id}/belongs-to-many/{relationship}/attachableList attachable records
POST/api/resources/{resource}/{id}/belongs-to-many/{relationship}/attachAttach record
DELETE/api/resources/{resource}/{id}/belongs-to-many/{relationship}/{relatedId}/detachDetach record
PUT/api/resources/{resource}/{id}/belongs-to-many/{relationship}/{relatedId}/pivotUpdate 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\Project
public 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 the aggregateVia() tile count the records the related resource's index would list (its scopes() and indexQuery(), 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\Project
public 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-one endpoints 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\Client
public 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 default

Model side:

// App\Models\TeamMember
public 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-many endpoints refuse one (403).
  • countBadge brings the count affordance to Through fields (in addition to showRelationCount on HasMany).

Upgrading the Through fields from 1.x

Martis 2.0 aligns HasOneThrough and HasManyThrough with Nova. In 1.x:

  • the has-many endpoints took a create through a hasManyThrough relationship and filed the new record under whichever intermediate had the parent's id, and so did the has-one endpoints when a plain HasOne field declared a hasOneThrough one;
  • canCreate() brought the Create button back on a Through panel, and a plain HasMany / HasOne field declaring a Through relationship showed it by default;
  • the Through panels hid Edit and Delete by default (canUpdate / canDelete started as false), and the has-one endpoints refused an update or a delete through a HasOneThrough field (403). The HasManyThrough panel 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 deny update / delete there.
  • 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 plain HasMany / HasOne field 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, and canCreate(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's client_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 caseField
FK stored on this modelBelongsTo
FK stored on related model, single recordHasOne
FK stored on related model, many recordsHasMany
Pivot table, DataTable UI, pivot fieldsBelongsToMany
Pivot table, chip/autocomplete UI, no pivot dataTag
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 pivotMorphToMany

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

BehaviourHasManyHasOneBelongsToManyMorphManyMorphOneMorphToMany
Listing scoped to parent✅n/a (single)✅✅n/a (single)✅
Cross-type isolation (same ID, different morph type)n/an/an/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/an/a✅n/an/a✅
canSee() pivot fields left out of the schema, _pivot and the validation, never written from the requestn/an/a✅n/an/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/an/a✅n/an/a✅
Pivot data round-trip on attach + index + updaten/an/a✅n/an/a✅
Pivot actions listed, described and run per panel; {relationship} resolves only to a declared field of the route's typen/an/a✅n/an/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 recordn/an/a✅n/an/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)policypolicy✅policypolicypivot routes only

Pivot data API (BelongsToMany & MorphToMany)

Pivot fields declared via ->fields(fn () => [Number::make('weight'), ...]) round-trip through three surfaces:

SurfaceBody shapeNotes
Index responsedata[*]._pivotPivot 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+).
Attachflat 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_id and lead_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:

  1. {id} names a record of the resource the user may update (the edit form): fieldsForUpdate(), on the resource bound to that record, so a relatableQueryUsing() closure on the field can read it through $this->model.
  2. {id} is _ (a create form), names no record, or names a record the user may not update: fieldsForCreate(), then fieldsForInlineCreate() (the inline-create modal), on a resource bound to no record.
  3. fields(), so a picker that only fields() 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 the viewAny check alone: a user who could not view or edit a record read the options its fieldsForUpdate() closures derived from it.

Before v1.38.0 the endpoint searched fields() only: a picker declared on a form alone answered Field '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:

  1. relatableQuery on the target resource — the generic fence the target declares for itself. It always runs.
  2. relatable{PluralModelName} on the source resource (specific override, gets passed the field) — narrows the already-fenced query for that source's relationships.
  3. Field-level relatableQueryUsing(fn ($request, $query) => ...) (BelongsTo / BelongsToMany / MorphToMany).
  4. Field-level withoutTrashed() (BelongsTo / BelongsToMany when the model uses SoftDeletes).

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) and relatable{PluralModelName}() replaced relatableQuery(). A consumer that only needs the index fence on its pickers declares it once in relatableQuery(); it no longer has to repeat it on every BelongsToMany / MorphToMany field 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:

WriteFieldSource resource
Create, update, inline create (POST /api/resources/{resource}, PUT .../{id}, POST .../inline-create)BelongsTo, MorphTo, TagThe resource
Inline create and update of a HasMany, HasOne, MorphMany, MorphOne panelBelongsTo, MorphTo, Tag of the related resourceThe related resource
Attach (one record or a batch) and pivot update of a BelongsToMany / MorphToMany panelThe attached record (related_id, related_ids, the {relatedId} of the pivot update) and the pivot fields' BelongsTo, MorphTo, TagThe parent resource
An Action or pivot action runThe Action's BelongsTo, MorphTo, TagThe resource the Action runs on (the parent resource for a pivot action)
Any of these with a RepeaterThe 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 / MorphTo needs the add{SourceModel} ability of the related record's policy (Nova's authorizedToAdd(); allowed when the policy does not define it): a task's owner is refused when UserPolicy::addTask($user, $owner) returns false, the ability the HasMany panel on the user's page checks before adding a task;
  • a BelongsTo / MorphTo fails when the picked record's inverse HasOne / MorphOne already holds another record (Nova's relationshipIsFull()): a profile cannot take a user whose HasOne profile is filled. The inverse is the related resource's HasOne / MorphOne field that points back at the resource of the write (not a one-of-many or Through field), or the one inverse() 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 Tag needs the source resource's attachAny{Model} and attach{Model} abilities for every record it adds, and detach{Model} for every record it removes (a null value removes them all), as the attach and detach endpoints; a removal the policy refuses answers 422 with martis::validation.detachable. Nova's Tag field checks none of them.

What passes without a check:

  • an empty value (the field's own required() / nullable() decide), a readonly field, and a MorphTo value that names no type of types() (it writes nothing);
  • the parent of a HasMany / HasOne / MorphMany / MorphOne inline 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 an InvalidArgumentException that 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') reads Task::team(), takes its related model and finds the resource registered for it), as Nova finds a BelongsTo resource 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: its relatableQuery(), the source's relatable{PluralModelName}(), the field's relatableQueryUsing() and withoutTrashed(), viewAny, the add{SourceModel} policy and the full inverse.
  • A pivot field of a BelongsToMany / MorphToMany panel reads its relationship from the pivot model (->using(Assignment::class) with an approver() 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 declare relatedResource() 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. Declare relatedResource() 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 Post never includes comments belonging to a Video with 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}/detach only removes attachments where taggable_type matches the parent class. A tag attached only to another type answers 404, like one that does not exist (v2.11.1).
  • MorphOneController::destroy never deletes a sibling morph type's relation that happens to share the same imageable_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

StatusWhen
200 / 201 / 204Success.
403Policy / authorizedToCreate / authorizedToView denial.
404Unknown 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.
422Validation 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).
500Bug — 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 on BelongsToMany and MorphToMany panels: listing, fields and run, the field's actions() against the resource's pivotAction(), {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 a relatableQueryUsing() 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() and immutable() 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's fill() on the attach (single and batch) and the pivot update of both panels: a fillUsing() callback, a MultiSelect, a computed field and a Boolean, 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 when authorizedToUpdate() 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, MorphTo and Tag pickers declared only in fieldsForCreate() / fieldsForUpdate() / fieldsForInlineCreate(), the form declaration winning over fields(), the record bound to the update form, the fields() fallback and the viewAny gate, plus the Slug check, dependsOn sync and Select search on a form-only field.
  • tests/Feature/ActionRelatableEndpointTest.php (13) — BelongsTo, MorphTo and Tag pickers 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 the viewAny / canSee() gates.
  • tests/Feature/PivotActionRelatableEndpointTest.php (12) — the same pickers in a pivot action modal, on BelongsToMany and MorphToMany panels: field actions and resource pivotAction() 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 and relatableQueryUsing(), 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 the viewAny / 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, and attach{Model} still deciding per record when it allows.
  • tests/Feature/RepeaterRowFieldLookupTest.php (21): pickers and a remote Select declared 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 the viewAny gates.
  • tests/Feature/ModelVisibilityWriteTest.php (14): a field canSeeForModel() 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 each HasMany / HasOne / MorphMany / MorphOne panel (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's hiddenPivotFields, the attached row on pivot update, in _pivot and in the pivot edit modal's pickers), and on a BelongsToMany / MorphToMany field 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 remote Select search of a resource and a Tool, the Slug check and the dependsOn sync 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), a HasMany inline 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 inverse HasOne and inverse(), the viewAny, add{Model}, attach{Model} and detach{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, and handle() receiving their default() (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:

  1. relatableQuery(Request $request, Builder $query): Builder — the generic fence on the target resource (the related resource itself). Always applied.
  2. relatable{PluralModelName}(Request $request, Builder $query [, FieldContract $field]): Builder — pluralized model basename of the related resource, looked up on the source resource. Example: a BelongsTo::make('Author', 'author', UserResource::class) looks for relatableUsers() on the resource calling BelongsTo::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.