# Relationships · Martis docs

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

Source: https://getmartis.com/docs/core/relationships

# 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](https://getmartis.com/docs/auth/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 exposed
```

### Panels 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 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](https://getmartis.com/docs/core/actions#the-action-events-panel-v201)). 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 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)

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](https://getmartis.com/docs/core/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

| 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](https://getmartis.com/docs/customization/overrides#naming-the-record-of-the-relationship-panels-v1380). |

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](https://getmartis.com/docs/auth/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](https://github.com/laravel/nova-dusk-suite/blob/10.4/tests/Browser/UpdateAttachedSoftDeletingTest.php)).

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()](https://getmartis.com/docs/core/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](https://getmartis.com/docs/core/fields#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](https://getmartis.com/docs/core/fields) 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`](https://getmartis.com/docs/core/relationships#tag-belongstomany-chip-ui) 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](https://getmartis.com/docs/core/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](https://getmartis.com/docs/core/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](https://getmartis.com/docs/core/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](https://getmartis.com/docs/core/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](https://getmartis.com/docs/core/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.

### 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](https://getmartis.com/docs/core/relationships#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](https://getmartis.com/docs/core/relationships#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](https://getmartis.com/docs/core/relationships#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](https://getmartis.com/docs/core/fields#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](https://getmartis.com/docs/core/fields#hasone) for the full API.

Note the static factory `HasOne::ofMany($name, $relationship, $resourceClass)` promotes a `hasMany()->latestOfMany()` relation into a [`HasOneOfMany`](https://getmartis.com/docs/core/relationships#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](https://github.com/laravel/nova-dusk-suite/blob/10.4/tests/Browser/HasOneAuthorizationTest.php); 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`](https://github.com/laravel/nova-dusk-suite/blob/10.4/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](https://github.com/laravel/nova-issues/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](https://raw.githubusercontent.com/laravel/nova-dusk-suite/10.4/lang/vendor/nova/en.json); 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](https://github.com/laravel/nova-dusk-suite/blob/10.4/tests/Browser/HasOneRelationTest.php)). 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](https://github.com/laravel/nova-issues/discussions/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](https://getmartis.com/docs/core/fields#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](https://getmartis.com/docs/core/fields#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](https://getmartis.com/docs/core/relationships#upgrading-the-through-fields-from-1x).

**⭐ 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](https://getmartis.com/docs/core/fields#morphone) for the full API.

## MorphOneOfMany

Polymorphic counterpart of [HasOneOfMany](https://getmartis.com/docs/core/relationships#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](https://getmartis.com/docs/core/fields#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](https://getmartis.com/docs/core/fields#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](https://getmartis.com/docs/core/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](https://getmartis.com/docs/core/fields#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](https://getmartis.com/docs/core/relationships#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, `ValidationRule`s, 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](https://getmartis.com/docs/core/relationships#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_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](https://getmartis.com/docs/core/relationships#pivot-actions)) and the parent resource as the source. See [Actions → Relation fields](https://getmartis.com/docs/core/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](https://getmartis.com/docs/core/relationships#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](https://getmartis.com/docs/core/repeater#relation-pickers-and-remote-selects-in-rows).

The [Slug](https://getmartis.com/docs/core/fields#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](https://getmartis.com/docs/core/fields#reactive-fields--dependsonfield-closure) and the server-side [`Select` search](https://getmartis.com/docs/core/fields#select) 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](https://getmartis.com/docs/core/relationships#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:

| 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` / `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()`](https://getmartis.com/docs/core/fields#belongsto) 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](https://getmartis.com/docs/core/resources#a-headless-resource-over-a-model-that-has-a-page) 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](https://getmartis.com/docs/core/relationships#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](https://getmartis.com/docs/core/relationships#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 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.

Fields

Filters
