# Fields — Complete Reference · Martis docs

> All available field types in Martis, their methods, and configuration options.

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

# Fields — Complete Reference

> Auto-generated from source code. **Every agent MUST update this file when adding or modifying fields or base field methods.**

All available field types in Martis, their methods, and configuration options.

## Table of Contents

- [Import](https://getmartis.com/docs/core/fields#import)
- [Base Field (Common Methods)](https://getmartis.com/docs/core/fields#base-field-common-methods)  [Factory](https://getmartis.com/docs/core/fields#factory) [Identity](https://getmartis.com/docs/core/fields#identity) [Value Resolution & Filling](https://getmartis.com/docs/core/fields#value-resolution--filling) [Fluent Configuration](https://getmartis.com/docs/core/fields#fluent-configuration) [Visibility](https://getmartis.com/docs/core/fields#visibility) [Granular Visibility](https://getmartis.com/docs/core/fields#granular-visibility) [Convenience Visibility Presets](https://getmartis.com/docs/core/fields#convenience-visibility-presets) [Context-Aware Visibility](https://getmartis.com/docs/core/fields#context-aware-visibility) [Field authorization: `canSee()` and `canSeeForModel()`](https://getmartis.com/docs/core/fields#field-authorization-cansee-and-canseeformodel) [Sortable / Searchable](https://getmartis.com/docs/core/fields#sortable--searchable) [Validation](https://getmartis.com/docs/core/fields#validation)  [What an update validates](https://getmartis.com/docs/core/fields#what-an-update-validates)   [Unique Validation](https://getmartis.com/docs/core/fields#unique-validation) [Customization Hooks](https://getmartis.com/docs/core/fields#customization-hooks)  [Computed fields](https://getmartis.com/docs/core/fields#computed-fields)   [Component Override](https://getmartis.com/docs/core/fields#component-override) [Metadata](https://getmartis.com/docs/core/fields#metadata) [Serialization](https://getmartis.com/docs/core/fields#serialization)
- [Tooltips (Martis differential)](https://getmartis.com/docs/core/fields#tooltips-martis-differential)
- [Field Types](https://getmartis.com/docs/core/fields#field-types)  [Id](https://getmartis.com/docs/core/fields#id) [Text](https://getmartis.com/docs/core/fields#text) [Textarea](https://getmartis.com/docs/core/fields#textarea) [Number](https://getmartis.com/docs/core/fields#number) [Boolean](https://getmartis.com/docs/core/fields#boolean) [BooleanGroup](https://getmartis.com/docs/core/fields#booleangroup) [Avatar](https://getmartis.com/docs/core/fields#avatar) [UiAvatar](https://getmartis.com/docs/core/fields#uiavatar) [Audio](https://getmartis.com/docs/core/fields#audio) [Select](https://getmartis.com/docs/core/fields#select) [GuardSelect](https://getmartis.com/docs/core/fields#guardselect) [MultiSelect](https://getmartis.com/docs/core/fields#multiselect) [Date](https://getmartis.com/docs/core/fields#date) [DateTime](https://getmartis.com/docs/core/fields#datetime) [Email](https://getmartis.com/docs/core/fields#email) [Password](https://getmartis.com/docs/core/fields#password) [PasswordConfirmation](https://getmartis.com/docs/core/fields#passwordconfirmation) [Slug](https://getmartis.com/docs/core/fields#slug) [Timezone](https://getmartis.com/docs/core/fields#timezone) [Icon](https://getmartis.com/docs/core/fields#icon) [Stack + Line](https://getmartis.com/docs/core/fields#stack--line) [Url](https://getmartis.com/docs/core/fields#url) [Color](https://getmartis.com/docs/core/fields#color) [Country](https://getmartis.com/docs/core/fields#country) [Currency](https://getmartis.com/docs/core/fields#currency) [BelongsTo](https://getmartis.com/docs/core/fields#belongsto) [Tag](https://getmartis.com/docs/core/fields#tag) [BelongsToMany](https://getmartis.com/docs/core/fields#belongstomany) [HasOne](https://getmartis.com/docs/core/fields#hasone) [HasOneOfMany](https://getmartis.com/docs/core/fields#hasoneofmany) [HasOneThrough](https://getmartis.com/docs/core/fields#hasonethrough) [HasMany](https://getmartis.com/docs/core/fields#hasmany) [HasManyThrough](https://getmartis.com/docs/core/fields#hasmanythrough) [MorphOne](https://getmartis.com/docs/core/fields#morphone) [MorphOneOfMany](https://getmartis.com/docs/core/fields#morphoneofmany) [MorphMany](https://getmartis.com/docs/core/fields#morphmany) [MorphTo](https://getmartis.com/docs/core/fields#morphto) [MorphToMany](https://getmartis.com/docs/core/fields#morphtomany) [File](https://getmartis.com/docs/core/fields#file) [Image](https://getmartis.com/docs/core/fields#image) [Code](https://getmartis.com/docs/core/fields#code) [Markdown](https://getmartis.com/docs/core/fields#markdown) [Trix](https://getmartis.com/docs/core/fields#trix) [KeyValue](https://getmartis.com/docs/core/fields#keyvalue) [Heading](https://getmartis.com/docs/core/fields#heading) [Hidden](https://getmartis.com/docs/core/fields#hidden) [Badge](https://getmartis.com/docs/core/fields#badge) [Badges](https://getmartis.com/docs/core/fields#badges) [Status](https://getmartis.com/docs/core/fields#status) [Gravatar](https://getmartis.com/docs/core/fields#gravatar) [Sparkline](https://getmartis.com/docs/core/fields#sparkline)
- [Utility Classes](https://getmartis.com/docs/core/fields#utility-classes)  [DeferredRelationSync](https://getmartis.com/docs/core/fields#deferredrelationsync) [FieldContext (Enum)](https://getmartis.com/docs/core/fields#fieldcontext-enum)

## Import

```
use Martis\Fields\Badge;
use Martis\Fields\BelongsTo;
use Martis\Fields\Audio;
use Martis\Fields\Avatar;
use Martis\Fields\Boolean;
use Martis\Fields\BooleanGroup;
use Martis\Fields\Code;
use Martis\Fields\Color;
use Martis\Fields\Country;
use Martis\Fields\Currency;
use Martis\Fields\Date;
use Martis\Fields\DateTime;
use Martis\Fields\Email;
use Martis\Fields\File;
use Martis\Fields\Gravatar;
use Martis\Fields\Heading;
use Martis\Fields\Hidden;
use Martis\Fields\Icon;
use Martis\Fields\Id;
use Martis\Fields\Image;
use Martis\Fields\KeyValue;
use Martis\Fields\Line;
use Martis\Fields\Markdown;
use Martis\Fields\MultiSelect;
use Martis\Fields\Number;
use Martis\Fields\Password;
use Martis\Fields\PasswordConfirmation;
use Martis\Fields\Select;
use Martis\Fields\Slug;
use Martis\Fields\Sparkline;
use Martis\Fields\Stack;
use Martis\Fields\Status;
use Martis\Fields\Tag;
use Martis\Fields\Text;
use Martis\Fields\Textarea;
use Martis\Fields\Timezone;
use Martis\Fields\Trix;
use Martis\Fields\UiAvatar;
use Martis\Fields\Url;
```

## Base Field (Common Methods)

All fields extend `Martis\Fields\Field` (abstract) which implements `Martis\Contracts\FieldContract`. These methods are available on **every** field type.

### Factory

| Method | Signature | Description |
| --- | --- | --- |
| `make` | `static make(string $attribute, ?string $label = null): static` | Create a new field instance. Label defaults to title-cased attribute name. |

```
Text::make('title')                    // label = "Title"
Text::make('first_name', 'First Name') // explicit label
```

### Identity

| Method | Signature | Description |
| --- | --- | --- |
| `attribute` | `attribute(): string` | Return the model attribute name (e.g. `"title"`). |
| `label` | `label(): string` | Return the human-readable label (e.g. `"Title"`). |
| `type` | `type(): string` | Return the field type identifier (e.g. `"text"`, `"boolean"`). Abstract — each subclass implements. |

### Value Resolution & Filling

| Method | Signature | Description |
| --- | --- | --- |
| `resolve` | `resolve(Model $model, ?string $attribute = null): mixed` | Read the field value from a model: the raw value comes from the `computed()` callback when set, from `$model->getAttribute()` otherwise, then the `resolveUsing()` callback runs if set. |
| `resolveForDisplay` | `resolveForDisplay(Model $model, ?string $attribute = null): mixed` | Resolve then apply `displayUsing()` callback. Use for index/detail serialization. |
| `fill` | `fill(Model $model, mixed $value): void` | Write a value to the model. Respects the `fillUsing()` callback, the `readonly` flag and `computed()` (a computed field writes nothing unless a `fillUsing()` callback translates it), on every built-in field (`Repeater` honours both since v1.38.0, and applies them, `canSee()` and `immutable()` to the fields inside its rows, see [Repeater](https://getmartis.com/docs/core/repeater#readonly-computed-hidden-and-immutable-row-fields)). |
| `hasStructuredValue` | `hasStructuredValue(): bool` | Whether the form submits this field's value as a list or a map rather than a scalar. `true` on `Repeater`, `MultiSelect`, `BooleanGroup`, `KeyValue`, `Tag`, `MorphTo` and `Sparkline`; override it on a custom field whose form value is structured. See [Structured values and file uploads](https://getmartis.com/docs/core/fields#structured-values-and-file-uploads). |
| `rejectsUnstructuredValue` | `rejectsUnstructuredValue(): bool` | Whether a value that is not a list or a map (a string that is not JSON for one, a number, a boolean) fails validation instead of reaching `fill()`. `true` for a structured field the package fills itself; `false` for a readonly or computed field, a field with a `fillUsing()` callback, and `MorphTo` (its `fill()` ignores anything that is not a target map). Override it on a custom field whose `fill()` ignores such a value. v1.38.0+. |

#### Structured values and file uploads

A form that uploads a file (a new `File` / `Image` was picked) is sent as `multipart/form-data`, and `FormData` carries strings and files only. The SPA therefore encodes every non-file value on that path in a shape the server reads back exactly as the JSON request path would send it:

| Form value | Multipart body | Server side |
| --- | --- | --- |
| `File` | the file | `$request->file()` |
| `null` / `undefined` | `''` | `null` (Laravel's `ConvertEmptyStringsToNull`), so a field can be cleared |
| `true` / `false` | `'1'` / `'0'` | what `Boolean::fill()` and the `boolean` rule expect |
| array or plain object | `JSON.stringify(value)` | decoded back to the array before validation and fill for every field whose `hasStructuredValue()` is `true` |
| other scalar | `String(value)` | as is |

The decoding runs in every resource controller (`ResourceController`, `HasMany` / `HasOne` / `MorphMany` / `MorphOne` inline forms) through `DecodesStructuredValues`, so a `Repeater`'s rows, a `MultiSelect`'s selection, a `BooleanGroup`'s flag map, a `KeyValue`'s pairs, a `Tag`'s ids, a `MorphTo`'s target or a `Sparkline`'s points survive a save that also uploads a file, validation rules such as `array` / `max:N` see the real list, and `fill()` receives the same value it gets on a JSON save.

A value that is not a list or a map is never stored: a non-empty string that does not decode to one (`"[object Object]"`, `"12,15"`, a JSON scalar) on either path, and a number or a boolean a JSON request sends (`{"meta": 5, "labels": true}`). The controller validates it with Laravel's `array` rule on top of the field's own rules, so the request fails with a 422 (`The <label> field must be an array.`) and the stored value stays as it was, whether or not the field declares an `array` rule. This holds on both request paths for every field whose `rejectsUnstructuredValue()` is `true`, so a stale SPA bundle or a hand-written API call cannot empty a `Repeater`, clear a `KeyValue` or `MultiSelect`, detach every `Tag` or store the text in a `BooleanGroup` column. It does not apply where no data is at risk: a readonly or computed field writes nothing, a `fillUsing()` callback decides which shapes it accepts, and a `MorphTo` ignores any value that is not a target map (an update form up to v1.37.3 sends a bare id for an untouched target, and a morph key can be a string). `null` and the empty string (what the multipart path sends for `null`) still clear the field. Since v1.38.0; in v1.37.3 only a field with its own `array` rule was protected from a string, and nothing stopped a number or a boolean sent on the JSON path from reaching `fill()` and emptying the field.

Custom fields whose form value is a list or a map should return `true` from `hasStructuredValue()` to join that path; a field whose value is a *string that happens to look like JSON* (a `Code` editor holding JSON, a `Textarea`) must leave it `false` so its text is never decoded.

Before v1.37.3 the multipart body carried `String(value)` for everything: structured values arrived as `"[object Object]"` / `"12,15"` (rows silently emptied, selections cleared or rejected with 422, maps stored as text) and an unchecked `Boolean` arrived as `"false"`, which PHP casts to `true`.

#### Structured values and Eloquent casts

`MultiSelect`, `KeyValue` and the multiple-file modes of `File` / `Image` persist a list or a map. Their `fill()` looks at the model before deciding who serialises it:

- **Uncast column** (a plain `string` / `text` / `json` column with no entry in `$casts`): the field writes a single JSON-encoded string, exactly as before.
- **JSON-family cast** (`'array'`, `'json'`, `'object'`, `'collection'` and their `'encrypted:…'` variants) or **class cast** (`AsArrayObject::class`, `AsCollection::class`, `AsEnumCollection::of(...)`, any `Castable`): the field hands the cast the PHP array and the cast serialises it once. Reading the attribute back through the cast yields an array (or the cast's object), never a JSON string.
- **Scalar cast** (`'string'`, `'integer'`, …): treated like an uncast column; the field encodes.

So both of these store `["available","reserved"]` once and read back as an array:

```
// Model
protected $casts = ['publish_states' => 'array'];
 
// Resource
MultiSelect::make('publish_states')->options([...]);
```

```
// Model: no cast on `publish_states`
// Resource
MultiSelect::make('publish_states')->options([...]);  // $model->publish_states is the JSON string; resolve() decodes it for the form
```

Before v1.37.3 a cast attribute was double-encoded (a JSON string *of* a JSON string) on the first save through the admin form, and every later read through the cast returned a string.

### Fluent Configuration

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `nullable` | `nullable(bool\|Closure $value = true): static` | `$this` | Mark as nullable (adds `nullable` validation rule). Accepts a closure for request-time resolution. |
| `sensitive` | `sensitive(bool $value = true): static` | `$this` | Mark the value as a secret: an Action's field that collects an API key or a token. The [action event log](https://getmartis.com/docs/core/actions#action-field-values-in-the-log) stores `******` for it instead of the value. `Password` and `PasswordConfirmation` are sensitive by default. See [Sensitive fields](https://getmartis.com/docs/core/fields#sensitive-fields). v2.4.0+. |
| `isSensitive` | `isSensitive(): bool` | `bool` | Whether the value is a secret. |
| `readonly` | `readonly(bool\|Closure $value = true): static` | `$this` | Prevent modification through UI. `fill()` becomes a no-op. Accepts a closure for request-time resolution. Every bundled input renders the field read-only (`Avatar`, `BooleanGroup`, `Repeater`, `File`, `Image` and the inline-create "+" of `BelongsTo` / `MorphTo` since v1.38.0, see [Immutable fields](https://getmartis.com/docs/core/fields#immutable-fields)). A readonly pivot field is never written from the request either: the attach stores its `default()` and the pivot update leaves it alone (v1.38.0+, see [Immutable fields](https://getmartis.com/docs/core/fields#immutable-fields)). Nor is a readonly field inside a `Repeater` row: a stored row keeps its value and a new row stores its `default()` (v1.38.0+, see [Repeater](https://getmartis.com/docs/core/repeater#readonly-computed-hidden-and-immutable-row-fields)). |
| `required` | `required(bool\|Closure $value = true): static` | `$this` | Require a non-null value (adds `required` validation rule). Accepts a closure for request-time resolution. **v1.8.3**: declaring `'required'` inside `->rules([...])` is enough, the visual asterisk auto-detects it. Calling `->required()` explicitly is still supported and required when you want a Closure-resolved flag. **v2.10.0**: only the exact `required` rule (or a rule object that renders as exactly `required`, `Rule::requiredIf(true)`) counts, as in Nova. A conditional rule (`required_if:…`, `required_with`, `required_unless`, `required_array_keys`, …) is evaluated when the request is validated: it neither adds the asterisk nor makes the field unconditionally required. See [Conditional required rules](https://getmartis.com/docs/core/fields#conditional-required-rules-v2100). |
| `placeholder` | `placeholder(string\|Closure $text): static` | `$this` | Set placeholder text for the input. Accepts a closure for request-time resolution. |
| `help` | `help(string\|Closure $text): static` | `$this` | Set help text displayed below the field input. Supports inline HTML (Martis extension): the panel sanitises it (links, bold, code, lists and line breaks stay; scripts, event handlers, `javascript:` URLs, the `style` attribute and element, `id`, `name`, forms and form controls are removed), but it is still output you author, so never interpolate unescaped user or record data into it (use `e()`). Accepts a closure for request-time resolution. |
| `tooltip` | `tooltip(string\|Closure\|null $text): static` | `$this` | ⭐ Martis differential. Attach a hover tooltip to the field label — shown via a `(?)` icon next to the label. Supports raw HTML so authors can use `<br />`, `<strong>`, `<em>`, `<ul>`, etc. for multi-line rich hints. Accepts a closure for request-time resolution. Pass `null` to clear. See [Tooltips](https://getmartis.com/docs/core/fields#tooltips-martis-differential). |
| `withLabel` | `withLabel(string\|Closure $value): static` | `$this` | Override the constructor label after construction. Accepts a closure for request-time resolution. |
| `fullWidth` | `fullWidth(bool $fullWidth = true): static` | `$this` | Make the field span the full width of the form. On the detail page and in the detail drawer the row becomes a single column with the label above the value (v2.23.0+; before, the flag was ignored there). |
| `hideLabelOnDetail` | `hideLabelOnDetail(bool $hide = true): static` | `$this` | Hide the field label on the detail page and in the detail drawer, so a long value uses the whole row. The label stays in the markup for screen readers; forms keep it. Implies the single-column row of `fullWidth()`. v2.23.0+. |
| `stacked` | `stacked(bool $stacked = true): static` | `$this` | Control label position on forms: stacked above (true) or inline (false). The detail rows do not read it (it defaults to `true`); use `fullWidth()` there. |
| `default` | `default(mixed $value): static` | `$this` | Set a default value for the field on create forms. Accepts a closure for request-time resolution, resolved on the server. The create page, the create drawer, the inline create modal and the action modals open the field on it (v2.16.0+; before, only a `Repeater` row and the attach modal's pivot fields did). A copy made with Replicate opens on the copied record's values instead. |
| `sticky` | `sticky(bool $value = true): static` | `$this` | Keep the value the user submitted when they choose "Create & add another", so the next record starts from it instead of the field's default; the other fields return to their defaults. Serialised as `sticky` in the field descriptor. Has no effect on update, replicate or the other submit buttons. |

### Visibility

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `showOnIndex` | `showOnIndex(): static` | `$this` | Show on the index (list) view. |
| `hideFromIndex` | `hideFromIndex(): static` | `$this` | Hide from the index view. |
| `showOnDetail` | `showOnDetail(): static` | `$this` | Show on the detail (show) view. |
| `hideFromDetail` | `hideFromDetail(): static` | `$this` | Hide from the detail view. |
| `showOnForms` | `showOnForms(): static` | `$this` | Show on create and edit forms. |
| `hideFromForms` | `hideFromForms(): static` | `$this` | Hide from create and edit forms. |
| `isShownOnIndex` | `isShownOnIndex(): bool` | `bool` | Check if visible on index. |
| `isShownOnDetail` | `isShownOnDetail(): bool` | `bool` | Check if visible on detail. |
| `isShownOnForms` | `isShownOnForms(): bool` | `bool` | Check if visible on forms. |

### Granular Visibility

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `hideWhenCreating` | `hideWhenCreating(): static` | `$this` | Hide on create form only. |
| `hideWhenUpdating` | `hideWhenUpdating(): static` | `$this` | Hide on update form only. |
| `showOnCreating` | `showOnCreating(): static` | `$this` | Show on create form. Relationship fields whose persistence requires a saved parent stay off the create form: `HasOne`, `HasMany`, `HasManyThrough`, `HasOneThrough`, `HasOneOfMany`, `MorphOne`, `MorphMany`, `MorphOneOfMany` are detail-only by default, and **`BelongsToMany` / `MorphToMany` never render on a create form** (since **v1.38.0**, like Nova): no visibility call brings them there, since a pivot row needs the record's key. Attach once the record exists, or use [`Tag`](https://getmartis.com/docs/core/fields#tag) to pick related records while creating. |
| `showOnUpdating` | `showOnUpdating(): static` | `$this` | Show on update form. |

### Convenience Visibility Presets

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `onlyOnIndex` | `onlyOnIndex(): static` | `$this` | Show only on index; hide everywhere else. |
| `onlyOnDetail` | `onlyOnDetail(): static` | `$this` | Show only on detail; hide everywhere else. |
| `onlyOnForms` | `onlyOnForms(): static` | `$this` | Show only on create/update forms; hide everywhere else. |
| `exceptOnForms` | `exceptOnForms(): static` | `$this` | Show everywhere except forms. |

### Context-Aware Visibility

| Method | Signature | Description |
| --- | --- | --- |
| `isVisibleForContext` | `isVisibleForContext(string $context): bool` | Check visibility for a given context. Contexts: `index`, `detail`, `create`, `update`, `inline-create`, `preview`. |
| `filterForContext` | `static filterForContext(array $fields, string $context): array` | Filter an array of fields to only those visible in the given context. |

**Resolution rules per context:**

| Context | Resolves from |
| --- | --- |
| `index` | `showOnIndex` |
| `detail` | `showOnDetail` |
| `create` / `inline-create` | `showOnCreate` ?? `showOnForms` |
| `update` | `showOnUpdate` ?? `showOnForms` |
| `preview` | `showOnPreview` ?? `showOnDetail` |

### Field authorization: canSee() and canSeeForModel()

| Method | Signature | Description |
| --- | --- | --- |
| `canSee` | `canSee(callable $callback): static` | Show the field only when `$callback(Request $request)` returns `true`. |
| `canSeeWhen` | `canSeeWhen(string $ability, mixed ...$arguments): static` | `canSee()` through a Gate ability of the current user. |
| `canSeeForModel` | `canSeeForModel(callable $callback): static` | Show the field on a record only when `$callback(Request $request, Model $model)` returns `true` for that record. |
| `canSeeUsingPolicy` | `canSeeUsingPolicy(string $ability): static` | `canSeeForModel()` through a Gate ability of the current user on the record. |
| `isAuthorizedToSee` | `isAuthorizedToSee(Request $request): bool` | Resolve `canSee()`. |
| `isAuthorizedForModel` | `isAuthorizedForModel(Request $request, Model $model): bool` | Resolve `canSeeForModel()` for a record. |
| `filterForModel` | `static filterForModel(array $fields, Request $request, Model $model): array` | The fields of a list the user may see on a record (v1.38.0). |
| `sortableAttributes` | `static sortableAttributes(array $items, Request $request): array` | The attributes a request may sort a list by: those of the `sortable()` fields the user may see, layout containers opened (v1.38.0). |
| `searchableFields` | `static searchableFields(array $items, Request $request): array` | The fields a search matches its term on: the `searchable()` fields the user may see, layout containers opened (v1.38.0). |
| `hiddenAttributes` | `static hiddenAttributes(array $fields, array $visible): array` | The attributes of the fields of a list a record hides, given the ones `filterForModel()` kept: a serialised record's `_hidden` (v1.38.0). |
| `filterLayoutFields` | `static filterLayoutFields(array $items, Closure $keep): array` | A field list without the fields `$keep` rejects, its layout containers rebuilt with the fields they keep and left out when they keep none (v1.38.0). |

A field the user cannot see is hidden by the server, not only by the UI. `canSee()` hides it for the request, `canSeeForModel()` for one record, and both hold on every endpoint:

- **Reads.** `canSee()` leaves the field out of every field list of the schema and of every value the API sends. `canSeeForModel()` leaves its value out of each record it hides the field for: the index, the detail, the values of the update form, the replicate form, the rows and the record of a relationship panel (and the responses of its inline create and update), the rows of a lens, the peek card, and for a pivot field the `_pivot` values of the attached records. The schema describes the resource, not a record, so each record lists under `_hidden` the attributes of the fields it hides (v1.38.0), and the pages leave those fields out instead of rendering them empty: the detail page, the detail and quick-look drawers, the update page and drawer (which neither render nor send them), the card of a `HasOne` / `MorphOne`, and the rows of the index, a lens and a relationship panel, where the field's cell stays empty for that record. A layout container left without fields is left out too. The `_pivot` values of an attached record list the pivot fields hidden for its pivot row the same way, and the panel leaves them out of the row and of its pivot edit form. The create forms leave out a field hidden for the new model (see **Creates** below).
- **Writes.** A hidden field is neither validated nor written, like a `readonly()` field: the request is accepted, the other fields are written and the column keeps its value. This holds on every endpoint that writes a record through its fields: the resource create and update, the inline create, the inline create and update of a `HasMany` / `HasOne` / `MorphMany` / `MorphOne` panel, and the attach and pivot update of a `BelongsToMany` / `MorphToMany` for its pivot fields (the attach stores the field's `default()`).
- **Creates.** `canSeeForModel()` is decided on the record written before any value of the request is written to it: the stored record on an update and, on a create, the new, unsaved model, as Nova resolves the fields of a create on a fresh model. A callback that grants on the record's stored values (an owner column, `exists`, a policy that reads them) denies on a create, and the create does not write the field. The create forms leave such a field out (v1.38.0): the schema's `fieldsForCreate` (a layout container left without fields goes with them) and `fieldsForInlineCreate`, and the inline-create schema, are decided on a new model. Grant on `! $model->exists` to let a create write it:

```
Currency::make('salary')
    ->canSeeForModel(fn (Request $request, Employee $employee): bool => ! $employee->exists
        || ($request->user()?->can('viewSalary', $employee) ?? false));
```

- **Pivot fields.** The model a pivot field's `canSeeForModel()` receives is the pivot row, an instance of the relationship's pivot class (the `->using()` class, else `Pivot` / `MorphPivot`) whose `pivotParent` is the parent record: the attached row when the pivot values are read or updated and when the pivot edit modal asks for a picker's options, a new row on attach and in the attach modal. The list of records to attach names the pivot fields a new row hides (`meta.hiddenPivotFields`), and the attach modal leaves them out (v1.38.0+; before, it offered an input the attach then ignored, storing the field's `default()`).
- **Relationship fields.** A `HasMany`, `HasOne`, `MorphMany`, `MorphOne`, `BelongsToMany` or `MorphToMany` field that `canSeeForModel()` hides for the parent record answers 404 on every endpoint of its panel (the list, its create, update and delete, the attach, the detach, the pivot update, the pivot actions and the pickers of its pivot fields), as a relationship the resource does not declare.
- **Per-field endpoints.** The options of a relation picker (on a form, among an Action's or a pivot action's fields, among pivot fields), the option search of a `Select` (of a resource or a Tool), the Slug check and the `dependsOn` sync answer for a hidden field exactly as for an attribute nothing declares: 404 for a picker and the Slug check, 422 for the `Select` search and the sync. So does a field of a Repeater row the user cannot see, and every row field of a Repeater the user cannot see. On a form, `canSeeForModel()` is decided on the record the update form edits, or on the new model of a create form.
- **Action fields.** An Action field the user cannot see is left out of the modal, is not validated and never takes its value from the request. See [Actions → Fields the request cannot set](https://getmartis.com/docs/core/actions#fields-the-request-cannot-set).
- **Sorting.** A list is sorted only by a `sortable()` field the user can see (v1.38.0): the order of the rows would otherwise tell the order of the field's values. A `?sort=` naming a field `canSee()` hides is ignored like one naming an unknown attribute (the other keys of a multi-key `?sort=` still apply), and the list keeps its default order. This holds on every list that takes `?sort=`: the resource index (its `defaultSort()` too), the rows of a `HasMany`, `MorphMany`, `BelongsToMany` and `MorphToMany` panel, and a lens, which is sorted by its own sortable fields only (see [Lenses → Which columns sort a lens](https://getmartis.com/docs/core/lenses#which-columns-sort-a-lens)).
- **Searching.** A search matches its term only on the `searchable()` fields the user can see (v1.38.0): the rows a term returns would otherwise tell which records hold it in a field the user may not read. This holds on every search built from the fields' `searchable()` flags: the resource index, the rows of a relationship panel and its attach picker, the global search (see [Global Search → Fields the user cannot see](https://getmartis.com/docs/core/global-search#fields-the-user-cannot-see)) and a relation picker (`BelongsTo`, `MorphTo`, `Tag`), which searches the title it shows when none of the related resource's searchable fields is left. A `field:value` token naming a field the user cannot see is dropped like one naming an unknown field. What a resource declares itself is searched as declared: its `searchableRelations()` paths, its `searchQuery()` predicate and, with Scout, its `toSearchableArray()`.
- **Layouts.** The rules are the same whatever layout container holds the field: `Panel`, `Section`, or directly a `Tab`.

A field inside a Repeater row follows `canSee()` (see [Repeater → Readonly, computed, hidden and immutable row fields](https://getmartis.com/docs/core/repeater#readonly-computed-hidden-and-immutable-row-fields)); `canSeeForModel()` is not applied to row fields.

`canSeeForModel()` is decided on one record, so it cannot be decided for a list or a search, which span many: a `sortable()` field it hides on some records still orders the lists that show them, and a `searchable()` one is still searched, so the order of the rows and the rows a term returns tell something about the values of the records it is hidden on. When they must stay hidden, do not make the field `sortable()` / `searchable()`, or hide it with `canSee()` from the users who may not see it on every record.

Before v1.38.0 `canSeeForModel()` only hid the value on the resource's own reads (the index, the detail, the update form values, the replicate form). The resource create and update, the inline create and the inline forms of a relationship panel validated a field it hides and wrote the value the request sent (and the update form, which sends every field it lists, emptied the column); the relationship panels, the lens rows and the peek card sent its value; a relationship field it hides still served its panel; and a pivot field's callback was never asked. The per-field endpoints found a field whatever `canSee()` said, and an Action field hidden with `canSee()` was listed in the modal, validated and handed to `handle()`. A `?sort=` naming a sortable field the user cannot see ordered the resource index, the relationship panels and a lens by that field, and a lens was sorted by any column `?sort=` named. Every search matched the searchable fields the user cannot see too, and a `field:value` token could name one. The create forms listed a field `canSeeForModel()` hides for the new model, and the pages rendered a field a record hides empty (a hidden `Boolean` read "No"), since nothing told it from an empty one. A field placed directly in a `Tab` ignored `canSee()` altogether: the schema listed it, the record's values carried it, and the create and the update validated it and wrote the value the request sent.

### Sortable / Searchable

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `sortable` | `sortable(bool $value = true): static` | `$this` | Allow sorting the index table by this field, for the users who can see it (see [Field authorization](https://getmartis.com/docs/core/fields#field-authorization-cansee-and-canseeformodel)). |
| `searchable` | `searchable(bool $value = true): static` | `$this` | Match the searches (resource index, relationship panels, relation pickers, global search) on this field, for the users who can see it (see [Field authorization](https://getmartis.com/docs/core/fields#field-authorization-cansee-and-canseeformodel)). |
| `sortUsing` | `sortUsing(Closure(Builder $query, SortDirection $direction): void $callback): static` | `$this` | Write the ordering by this field yourself, for an attribute that is not a plain column of the listed table. Implies `sortable()`. See [Sorting by a computed key](https://getmartis.com/docs/core/fields#sorting-by-a-computed-key-sortusing). |
| `isSortable` | `isSortable(): bool` | `bool` | Check if sortable. |
| `isSearchable` | `isSearchable(): bool` | `bool` | Check if searchable. |

#### Sorting by a computed key (sortUsing())

A key that is not a column of the listed table (a category's position, kept in another table) cannot be ordered by `orderBy($attribute)`. `sortUsing()` hands the ordering to a closure, which receives the query and the requested `SortDirection`:

```
BelongsTo::make('Category', 'category')
    ->sortUsing(fn (Builder $query, SortDirection $direction) => $query->orderBy(
        Category::select('position')->whereColumn('categories.id', 'promotion_materials.category_id'),
        $direction->value,
    )),
```

- It implies `sortable()`.
- It applies on every surface that sorts by the attribute: the index, a lens and the relationship panels, so a panel and the index agree, and in a multi-key `?sort=` and in `defaultSort()`.
- The closure only adds order clauses; it must not add `where` clauses (documented, not enforced).
- A field the user cannot see (`canSee()`) is not sortable through it either: the closure never runs.

### Validation

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `rules` | `rules(array\|Closure $rules): static` | `$this` | Add validation rules that apply on every context (merged with existing). Accepts a closure for request-time resolution; closure replaces any prior static rule list (and vice versa). See [Closure-aware setters](https://getmartis.com/docs/core/fields#closure-aware-setters). |
| `creationRules` | `creationRules(array $rules): static` | `$this` | Rules that apply ONLY in the create context: POST `/resources/{r}`, the inline create of a `HasMany` / `HasOne` / `MorphMany` / `MorphOne`, and the attach of a `BelongsToMany` / `MorphToMany` (pivot fields). Layered on top of `rules()`. |
| `updateRules` | `updateRules(array $rules): static` | `$this` | Rules that apply ONLY in the update context: PUT `/resources/{r}/{id}`, the inline update of a `HasMany` / `HasOne` / `MorphMany` / `MorphOne`, and the pivot update of a `BelongsToMany` / `MorphToMany` (pivot fields). Layered on top of `rules()`. |
| `buildRules` | `buildRules(?string $context = null): array` | `array` | Build the final rule array. Pass `'create'` or `'update'` to layer the matching context rules. |
| `validationMessages` | `validationMessages(): array` | `array` | Custom validation messages (e.g. for unique). |

**Context-aware example** — password is required on create but optional on update (the canonical pattern):

```
Password::make('password')
    ->rules(['min:8'])              // applies on every context
    ->creationRules(['required'])   // create only
    ->updateRules(['nullable']);    // update only
```

Every endpoint that writes through fields calls `buildRules('create')` on create and `buildRules('update')` on update: the resource's own POST and PUT, the inline create and update of the `HasMany` / `HasOne` / `MorphMany` / `MorphOne` panels, and the attach and pivot update of `BelongsToMany` / `MorphToMany` pivot fields. The schema endpoint also exposes both rule sets under `creationRules` / `updateRules` keys so the React frontend can pre-validate per context.

When `creationRules` contains `required`, the base `sometimes` rule is automatically stripped — `sometimes` short-circuits validation when a key is missing and would defeat the `required` directive otherwise.

#### Conditional required rules (v2.10.0)

A field whose rules carry a conditional sibling of `required` (`required_if:type,company`, `required_unless`, `required_with`, `required_without`, `required_if_accepted`, `required_array_keys`, ...) is required only when Laravel's condition holds:

```
Select::make('type')->options(['person' => 'Person', 'company' => 'Company'])->rules(['required']),
Text::make('first_name')->rules(['nullable', 'string', 'required_if:type,person']),
Text::make('legal_name')->rules(['nullable', 'string', 'required_if:type,company']),
```

A `person` payload without `legal_name` is valid, and one without `first_name` fails with a `422` on `first_name`. Martis compares as Nova does (`in_array('required', $rules)`): only the exact `required` string, or a rule object that renders as exactly `required` (`Rule::requiredIf(true)`), flags the field as required, adds the asterisk and puts a literal `required` in front of the rules. Before v2.10.0 any rule that started with `required` did, so two mutually exclusive `required_if` fields made every create fail. Because `sometimes` skips every rule for a key the request leaves out, the base rules also leave it out when a conditional required rule is present, or `required_if` could never refuse a missing key.

A conditional field shows no asterisk. To show one, call `->required()` (always required), or declare `Rule::requiredIf(true)` or `'required'` when it is. On update the rules behave as [below](https://getmartis.com/docs/core/fields#what-an-update-validates): a key the request does not send runs none of them.

#### What an update validates

On update a field is validated only when the request sends it. The controller drops the literal `required` string from the field's rules and puts `sometimes` first, so a payload that carries some fields leaves the others alone. Every other rule is kept, whatever its type: strings such as `required_with:…`, `Rule::` builder objects (`Rule::unique(...)->ignore(...)`, `Rule::in()`, `Rule::enum()`, `Rule::requiredIf()`), `ValidationRule` instances and closures. A key the request sends runs all of them (an implicit rule such as `Rule::requiredIf()` rejects a sent empty value); a key it does not send runs none.

```
Text::make('code')
    ->required()   // dropped on update: an update may omit `code`
    ->rules([
        // `$this->model` is the record being edited (unsaved, or null, on create).
        Rule::unique('invoices', 'code')->ignore($this->model?->getKey()),
        function (string $attribute, mixed $value, Closure $fail): void {
            if (str_contains((string) $value, ' ')) {
                $fail('The :attribute may not contain spaces.');
            }
        },
    ]);
```

This holds on every update endpoint: the resource's own PUT, the inline update of a `HasMany` / `HasOne` / `MorphMany` / `MorphOne`, and the pivot update of a `BelongsToMany` / `MorphToMany`. A record edited from its parent's detail page is validated exactly like the same record edited on its own page. Take the record to ignore from `$this->model`, not from the route: on the relationship endpoints the `{id}` route parameter is the parent's id. On a pivot update `$this->model` is the parent record; the pivot row is the route's `{relatedId}`.

Up to v1.37.3 the relationship endpoints validated less. The pivot update kept string rules only (every rule object and closure was skipped); the inline updates skipped closures and `Rule::enum()` / `Rule::requiredIf()` objects and answered any update of a related resource that declares a `ValidationRule` instance with a 500; and no relationship endpoint applied `creationRules()` / `updateRules()`.

### Immutable fields

`immutable()` flags a field as **writable on create, readonly on update**. On update the field is skipped silently: the request is accepted, the other fields are written and the column keeps its stored value. This holds on every endpoint that updates a record through its resource's fields (the resource's own PUT and the inline update of a `HasMany` / `HasOne` / `MorphMany` / `MorphOne` from the parent's detail page) and on the pivot update of a `BelongsToMany` / `MorphToMany` for its pivot fields. The resource's own POST, the inline creates and the attach write the value. A value the update sends still runs the field's rules, like any other field (see [What an update validates](https://getmartis.com/docs/core/fields#what-an-update-validates)).

```
Text::make('slug')->immutable()->required();
```

Common cases: slugs, account numbers, document references.

The forms follow the same split. The update forms render an immutable field read-only, exactly as they render a `readonly()` field, and still submit its stored value: the update page (also when a relation panel opens the record to edit it), the update drawer (`DrawerOverride::update()`) and the form that edits a pivot row. The create page, the create drawer, the inline create and the attach keep the input editable. The schema serialises the flag as `immutable`; a form built with `useMartisForm({ context: 'update' })` (see [Tool fields](https://getmartis.com/docs/customization/tool-fields)) resolves it the same way, and a custom input registered for the field type receives the field with `readonly: true`. A field inside a `Repeater` row is immutable row by row (v1.38.0+): a new row writes and validates the value it sends, a row the record stores keeps its value, on every storage mode, and an update form renders the field read-only on the stored rows only; an immutable `Repeater` as a whole is skipped on update like any field. See [Repeater → Readonly, computed, hidden and immutable row fields](https://getmartis.com/docs/core/repeater#readonly-computed-hidden-and-immutable-row-fields). Up to v1.37.3 every form rendered an immutable field as an editable input, and the update dropped the new value silently.

Every bundled input renders a readonly field read-only, so the lock holds whatever the field type. The inputs with more than one control lock each of them: `Avatar` shows the stored image with the picker disabled and no Choose file / Remove buttons; `BooleanGroup` disables every checkbox; `Repeater` shows its rows (the collapse toggles still work) with read-only row fields and no control that adds, removes, duplicates, reorders or pastes rows; `File` / `Image` keep their download links and previews, drop the remove and Change buttons, disable the picker and ignore a dropped file; `Audio` keeps its player, drops the Replace and Remove buttons, disables the picker and ignores a dropped file; `BelongsTo` and `MorphTo` disable their pickers and offer no inline-create "+" (`showCreateRelationButton()`), and a record the modal creates after the field turned readonly leaves the value alone (v1.38.0+). A file dropped on a readonly `File`, `Image` or `Audio` input is still cancelled, so the browser does not open it in place of the form. Up to v1.37.3 `Avatar`, `BooleanGroup`, `Repeater`, `File` and `Image` ignored `readonly()` (`File` / `Image` only disabled their hidden file input), so a readonly field of those types took a change in the form that the save then dropped (200, column unchanged), and a file dropped on a readonly `Audio` field was not cancelled: the browser opened it in the tab and left the form. Before v1.38.0 the "+" of a readonly `BelongsTo` / `MorphTo` stayed live: it created a real record and selected it, the update dropped the new value and left a record nothing points to, and on a nested create it replaced the locked parent on screen while the record was still created under the parent in the URL.

The pivot endpoints write each pivot field (in the `fields()` of a `BelongsToMany` / `MorphToMany`) through its `fill()`, and `readonly()` holds there too: a readonly pivot field never takes its value from the request. The attach stores its `default()` instead, as it does for any pivot field the request omits, and the pivot update leaves the column alone. See [Relationships → With Pivot Fields](https://getmartis.com/docs/core/relationships#with-pivot-fields).

Up to v1.37.3 only the resource's own update skipped an immutable field: the inline update of a `HasMany` / `HasOne` / `MorphMany` / `MorphOne` and the pivot update wrote it like any other field, and the attach and the pivot update also wrote a readonly pivot field from the request.

### Sensitive fields

`sensitive()` marks the value a field carries as a secret (v2.4.0+): an API key, a token or a one-time code that an Action collects in its modal under a plain `Text` field. The [action event log](https://getmartis.com/docs/core/actions#action-field-values-in-the-log) keeps `******` (`ActionEventRedactor::MASK`) in place of the value, in the event of a run, a failed run, a queued run and a pivot action. `Password` and `PasswordConfirmation` are sensitive without the call (`Password::make('x')->sensitive(false)` turns it off); `isSensitive()` reads the flag.

```
Text::make('api_key')->sensitive();
```

The flag changes nothing else: the action receives the value, the field is validated and rendered as before.

### Reactive fields — dependsOn(['field'], Closure)

⭐ **Martis differential** — declare that a field reacts to one or more sibling fields. The frontend watches the listed attributes and, every time the user edits any of them, posts the live form payload to `POST /api/resources/{r}/sync-field`. The backend re-runs the closure with the fresh data and returns the updated field descriptor (visibility, readonly, required, options, placeholder, help, default, meta, …) — the live form replaces its local descriptor with the response.

The closure receives:

- `array<string, mixed> $formData` — the live form values, keyed by field attribute (the watched siblings are guaranteed; any other touched attribute is forwarded too).
- `Illuminate\Http\Request $request` — the active request (auth user, locale, headers).
- `static $field` — the field instance, mutable. Call any of the regular fluent methods on it.

Examples:

```
// Make `price` required only when `is_paid` is true:
Number::make('price')
    ->dependsOn(['is_paid'], function (array $form, Request $r, Number $field) {
        $field->required((bool) ($form['is_paid'] ?? false));
    });
 
// Reload Select options whenever `category_id` changes:
Select::make('subcategory_id')
    ->dependsOn(['category_id'], function (array $form, Request $r, Select $field) {
        $field->options(
            \App\Models\Subcategory::query()
                ->where('category_id', $form['category_id'] ?? null)
                ->pluck('name', 'id')
                ->all(),
        );
    });
 
// Branch on the current user — works in both create and update contexts:
Text::make('access_code')
    ->dependsOn(['plan'], function (array $form, Request $r, Text $field) {
        if (($form['plan'] ?? null) === 'enterprise' && $r->user()?->isAdmin()) {
            $field->readonly(false);
        } else {
            $field->readonly(true);
        }
    });
```

#### Show, hide and relabel a field from a sibling (v2.17.0)

Call `hide()` and `show()` on the field, as Nova's dependent fields do. A field that starts hidden calls `hide()` where it is defined; the callback shows it when the sibling asks for it. The callback also runs when the form opens, so an update form shows or hides the field from the record's current values before anything is edited. `withLabel()` in the callback changes the label the same way.

```
Select::make('language')->options(['pt' => 'Portuguese', 'en' => 'English', 'both' => 'Both languages']),
 
Text::make('title')
    ->dependsOn(['language'], function (array $form, Request $r, Text $field) {
        $field->withLabel(($form['language'] ?? null) === 'both' ? 'Title (PT)' : 'Title');
    }),
 
Text::make('title_en')
    ->hide()
    ->rules(['nullable', 'required_if:language,both'])
    ->dependsOn(['language'], function (array $form, Request $r, Text $field) {
        ($form['language'] ?? null) === 'both' ? $field->show()->withLabel('Title (EN)') : $field->hide();
    }),
```

- A hidden field is not drawn, and its value is left out of the submit (not sent empty), so an update keeps the stored value. A panel, section or tab whose fields are all hidden is not drawn either.
- The server validates with the field's own rules, not with the callback: a field that can be hidden must not be plain `required`. Use a conditional rule (`required_if:language,both` above), which the hidden case (no value sent) satisfies.
- `hide()` differs from `hideFromForms()`: `hideFromForms()` takes the field off the form schema for good, `hide()` keeps it there so `show()` can bring it back.

| Method | Signature | Description |
| --- | --- | --- |
| `hide` / `show` | `hide(): static`, `show(): static` | Hide the field on its form (not drawn, not submitted) or show it again. Serialized as `hidden`. |
| `dependsOn` | `dependsOn(array $fields, ?Closure $callback = null): static` | Declare reactivity. The closure is optional; passing `null` lets layouts forward the watch list without running a callback (the Repeater uses this form for parent-attribute exposure). |
| `dependentFields` | `dependentFields(): list<string>` | Read the watched attributes. |
| `isDependent` | `isDependent(): bool` | True only when both `$fields` and a `Closure` are configured. The sync endpoint refuses non-reactive fields. |
| `syncDependent` | `syncDependent(array $formData, Request $request): static` | Run the closure against the given payload. Mutates `$this`. Useful in tests. |

**Wire format.** The schema endpoint serializes `dependsOn: { fields: ['attr1', 'attr2'] }` (an object) for reactive fields and `null` for non-reactive ones. The frontend `useDependsOnSync` hook subscribes to the listed attributes, debounces 200ms, and POSTs the payload when any watched value changes — older requests are aborted via `AbortController` so the latest value always wins.

**Endpoint.** `POST /api/resources/{r}/sync-field` body: `{ field: string, formData: object, context?: 'create' | 'update' }`. Response: a single field descriptor in the same shape as `schema.fields[]`. Authorization gates the same as create/update. The field is looked up on the form of `context` only, layout containers included: `fieldsForUpdate()` for `update`, `fieldsForCreate()` then the inline-create modal's `fieldsForInlineCreate()` for `create` (v1.38.0: a reactive field declared on the inline-create form alone used to answer 422). A field that is not on that form is `422 Unknown field`.

### Closure-aware setters

Most field setters accept either a static value or a closure that resolves at request time. Reach for the closure form whenever the result depends on the authenticated user, the locale, the active tenant, or anything else that lives on the `Request`.

The closure receives the active `Request` (or `null` when called outside an HTTP context, e.g. a queue worker) and must return the resolved value. Static values still work — the closure form is purely additive.

| Setter | Signature | Lazy reader |
| --- | --- | --- |
| `readonly` | `readonly(bool\|Closure $value = true): static` | `isReadonly(): bool` |
| `required` | `required(bool\|Closure $value = true): static` | `isRequired(): bool` |
| `nullable` | `nullable(bool\|Closure $value = true): static` | `isNullable(): bool` |
| `default` | `default(mixed $value): static` | `getDefaultValue(): mixed` |
| `placeholder` | `placeholder(string\|Closure $text): static` | `getPlaceholder(): ?string` |
| `help` | `help(string\|Closure $text): static` | `getHelp(): ?string` |
| `tooltip` | `tooltip(string\|Closure\|null $text): static` | `getTooltip(): ?string` |
| `withLabel` | `withLabel(string\|Closure $value): static` | `label(): string` |
| `rules` | `rules(array\|Closure $rules): static` | `buildRules(): array` |
| `Select::options` | `options(iterable\|Arrayable\|string\|Closure $options): static` | `getOptions(): list` |
| `MultiSelect::options` | `options(iterable\|Arrayable\|string\|Closure $options): static` | `getOptions(): list` |
| `BooleanGroup::options` | `options(array\|Closure $options): static` | `getOptions(): array` |

Examples:

```
// Per-user defaults
Text::make('owner_id')
    ->default(fn ($request) => $request?->user()?->id);
 
// A default read from the create link (`/resources/versions/create?of=12`).
// The resource lists the key in `acceptsPrefill()`; see
// resources.md#prefill-the-create-form-from-a-link--acceptsprefill
BelongsTo::make('document', 'Document')
    ->default(fn (?Request $request) => $request?->query('of'));
 
// Keep the category for the next record on "Create & add another"
Select::make('category')->options($categories)->sticky();
 
// Conditional readonly based on policy
Text::make('email')
    ->readonly(fn ($request) => $request?->user()?->cannot('change-email'));
 
// Required only for non-admins
Text::make('reason')
    ->required(fn ($request) => $request?->user()?->cannot('skip-reason'));
 
// Locale-aware label and placeholder
Text::make('greeting')
    ->withLabel(fn () => __('fields.greeting.label'))
    ->placeholder(fn () => __('fields.greeting.placeholder'));
 
// Help text that reads live state from the user. help() is HTML: escape
// anything that is not yours with e() (the panel sanitises it too).
Text::make('quota')
    ->help(fn ($request) => 'Quota left: '.e($request?->user()?->quota()));
 
// Options pulled from the database: Select / MultiSelect / BooleanGroup.
// All three read [value => label], as in Nova, so pluck('name', 'id') stores the id.
Select::make('owner_id')
    ->options(fn () => User::query()->pluck('name', 'id')->all());
 
MultiSelect::make('skills')
    ->options(fn () => [
        'php'   => ['label' => 'PHP', 'group' => 'Backend'],
        'go'    => ['label' => 'Go', 'group' => 'Backend'],
        'react' => ['label' => 'React', 'group' => 'Frontend'],
    ]);
 
// BooleanGroup reads [flag key => label]: availablePermissions() returns that map.
BooleanGroup::make('permissions')
    ->options(fn ($request) => $request?->user()?->availablePermissions()->all() ?? []);
 
// Validation rules that vary per role
Text::make('cap')
    ->rules(fn ($request) => $request?->user()?->isAdmin()
        ? ['nullable']
        : ['required', 'integer', 'max:100']);
```

#### withLabel() vs the constructor label

`Field::make('attribute', 'Label')` accepts the label as a positional argument. `withLabel()` is the post-construction setter that also accepts a closure. The contract method `label()` is the **getter** — `withLabel()` is named explicitly to avoid overloading it.

#### rules(callable) semantics

Static and closure rule definitions do **not** compose. The last call wins:

```
Text::make('field')
    ->rules(['min:1'])      // pinned
    ->rules(fn () => ['email']); // closure replaces ['min:1']
 
Text::make('field')
    ->rules(fn () => ['email']) // closure
    ->rules(['min:1']);         // static replaces the closure
```

If you need both static and dynamic rules, return them together from a single closure:

```
->rules(fn ($request) => array_merge(['min:1'], $request?->user()?->isAdmin() ? [] : ['max:100']))
```

### Unique Validation

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `unique` | `unique(array $config, ?string $message = null): static` | `$this` | Mark as unique. Config: `[table]` or `[table, column]`. |
| `setUniqueIgnoreId` | `setUniqueIgnoreId(int\|string\|null $id): void` | `void` | Set ID to ignore for unique validation (used on updates). |

```
Email::make('email')
    ->unique(['users', 'email'], 'This email is already in use.')
```

#### When the database index refuses the write (v2.9.0)

The rule runs before the write, so a unique index can still refuse it: two saves racing past the rule, or an index the rule does not mirror (a case-insensitive `lower(email)` index, a composite index). Martis answers that write with a `422`, as the rule would, on every endpoint that saves a record: create, update, inline create, and a relationship panel's create, update, attach and pivot update. The error sits on each field of the form whose column the index covers, with the field's `unique()` message, or else Laravel's `validation.unique` line on its label ("The Email has already been taken."). The response's `message` is `A record with this value already exists. Please use a unique value.`

The columns come from the database's error message:

| Database | It reports | Martis reads |
| --- | --- | --- |
| PostgreSQL | the key: `Key (lower(email::text))=(…)`, `Key (tenant_id, email)=(…)` | the column names in the key (function names, casts and string literals skipped); the constraint name only when the message carries no key |
| SQLite | the columns (`UNIQUE constraint failed: users.email`), or the index name for an expression index | the columns, or the index name |
| MySQL, MariaDB | the index name only (`for key 'users.users_email_unique'`); `PRIMARY` for the primary key | the columns the index name contains, split on the table's columns, longest first |

An index name works when it contains the column, as Laravel's default `{table}_{column}_unique` does (`users_email_lower_unique` works too). It is split on every column of the table, so `users_first_name_unique` names `first_name`, never a written `name`. A name that contains none of the form's columns (`uniq_domain`), or an index on a column the form does not write, answers the same `422` without a field error, so the form shows the message only. Only the fields of the form are matched, so a function name (`lower`) or a column the form lacks never becomes an error key. A foreign key or `NOT NULL` failure answers a `500` with a message of its own. No answer ever carries SQL or the database's message.

### Customization Hooks

The core hooks let you replace the default read / write / format behaviour of a field with arbitrary logic. Unlike the lazy setters in [Closure-aware setters](https://getmartis.com/docs/core/fields#closure-aware-setters), these hooks do not have a static counterpart — they ARE the customization.

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `resolveUsing` | `resolveUsing(callable $callback): static` | `$this` | Override value resolution. Callback: `fn(mixed $value, Model $model, string $attribute, ?Request $request): mixed` |
| `fillUsing` | `fillUsing(callable $callback): static` | `$this` | Override model filling. Callback: `fn(Model $model, mixed $value, string $attribute, ?Request $request): void` |
| `displayUsing` | `displayUsing(callable\|list<callable> $callback): static` | `$this` | Override display formatting (applied after resolveUsing, does NOT affect form values). Single callable: `fn(mixed $value, Model $model, string $attribute, ?Request $request): mixed`. Pass an array of callables to compose a pipeline. |
| `computed` | `computed(?callable $callback = null): static` | `$this` | Mark the field as computed: the raw value never comes from `$model->getAttribute()`. Callback: `fn(Model $model, string $attribute, ?Request $request): mixed`. Without a callback the raw value is `null` (pair with `resolveUsing()`). Hidden from forms; `fill()` is a no-op unless `fillUsing()` is set. See [Computed fields](https://getmartis.com/docs/core/fields#computed-fields). |

#### ⭐ Martis differential 1 — ?Request is forwarded to every hook

The active `Request` (or `null` when there is no HTTP context, e.g. a queue worker) is forwarded as the **fourth argument** of every hook. Closures with three parameters keep working unchanged because PHP silently drops trailing arguments that the callback did not declare.

```
// Per-tenant resolve without calling request() manually:
Text::make('balance')->resolveUsing(
    fn ($value, $model, $attribute, ?Request $request) =>
        $request?->user()?->isAdmin()
            ? number_format((float) $value, 2)
            : '*****',
);
 
// Per-locale fill that auto-converts decimals:
Number::make('price')->fillUsing(
    fn (Model $model, $value, string $attribute, ?Request $request) =>
        $model->setAttribute(
            $attribute,
            (float) str_replace(
                $request?->getPreferredLanguage(['pt_BR', 'en']) === 'pt_BR' ? ',' : '.',
                '.',
                (string) $value,
            ),
        ),
);
```

#### ⭐ Martis differential 2 — displayUsing() accepts a chainable pipeline

`displayUsing()` accepts either a single callable (legacy) or a `list<callable>`. When an array is passed, each callback receives the output of the previous one — equivalent in spirit to `array_reduce`.

```
// Pipeline: cast → format → prefix
Text::make('amount')->displayUsing([
    fn ($v) => (float) $v,
    fn ($v) => number_format($v, 2),
    fn ($v) => "R$ {$v}",
]);
 
// Pipeline + per-stage Request access:
Text::make('balance')->displayUsing([
    fn ($v) => (float) $v,
    fn ($v, $m, $attr, ?Request $r) => $r?->user()?->isAdmin()
        ? number_format($v, 2)
        : '*****',
    fn ($v) => "<strong>{$v}</strong>",
]);
```

If any entry in the array is not callable, `displayUsing()` throws `InvalidArgumentException` immediately at definition time — you get the failure when the resource boots, not deep inside a request.

A subsequent single-callable call replaces the entire pipeline. A subsequent array call replaces the prior single callable. The hook is monotonic: only the most recent definition is active.

#### Backward compatibility

| Old code | Still works? |
| --- | --- |
| `displayUsing(fn ($v) => ...)` | ✅ |
| `displayUsing(fn ($v, $m) => ...)` | ✅ |
| `displayUsing(fn ($v, $m, $attr) => ...)` | ✅ |
| `resolveUsing(fn ($v, $m, $attr) => ...)` | ✅ |
| `fillUsing(fn ($m, $v, $attr) => ...)` | ✅ |

The 4th `?Request $request` argument is purely additive. Existing 3-arg callbacks keep their semantics unchanged.

#### Computed fields

Every field has an attribute name, and by default `resolve()` reads that name from the model before anything else runs. That read is Eloquent's `getAttribute()`, which has two traps a `resolveUsing()` callback cannot avoid, because the read happens first:

- A name that matches a **model method** (`mode`, `status`, `type`, …) is treated as a relationship: `getAttribute('mode')` calls `mode()` and throws `LogicException: …::mode must return a relationship instance` when the method returns anything else (an enum, a string, a DTO). On the resource index that surfaces as an HTTP 500.
- A name with **no backing column** raises `MissingAttributeException` on persisted models once the host enables `Model::shouldBeStrict()`.

`computed()` opts the field out of that read. The attribute name becomes a pure payload key (it still has to be unique within the resource) and the value comes from a callback:

```
// Value source: model-first callback (recommended)
Badge::make('mode', 'Mode')
    ->computed(fn (Channel $channel): string => $channel->mode()->value)
    ->map(['push' => 'info', 'feed' => 'success']);
 
// Flag only: the existing resolveUsing() receives null as $value
Badge::make('mode', 'Mode')
    ->computed()
    ->resolveUsing(fn ($value, Channel $channel) => $channel->mode()->value);
```

Callback signature: `fn (Model $model, string $attribute, ?Request $request): mixed`. As with the other hooks, trailing arguments are optional: `fn (Channel $channel) => …` works.

The computed value enters the normal pipeline, `computed → resolveUsing → displayUsing`, so formatting and per-request logic work exactly as for stored fields. Without a callback the raw value is `null`: `resolveUsing()` receives `null`, and with no `resolveUsing()` either the field resolves to `null`. A later `computed()` call replaces the callback.

What `computed()` changes besides the read:

| Concern | Behaviour |
| --- | --- |
| Forms | Hidden from create and update (`hideFromForms()`), like Nova's computed fields. Call `showOnForms()`, `showOnCreating()` or `showOnUpdating()` **after** `computed()` to show it again. |
| `fill()` | No-op: there is nothing to write. A `fillUsing()` callback still runs, so a computed `full_name` can write `first_name` / `last_name` on save. `readonly()` keeps precedence. |
| `sortable()` / `searchable()` / filters | Not overridden, and not supported: there is no column to sort or search by, and a `Filter` whose column is the computed attribute would query a column that does not exist. Leave them off. |
| Introspection | `isComputed(): bool`. Not part of `toArray()`; the SPA does not need it. |

Which fields honour `computed()`: every field whose value resolution goes through `Field::resolve()` (`Text`, `Badge`, `Status`, `Number`, `Boolean`, `Date`, `Select`, …) plus the ones with their own `resolve()` that read through the shared seam: `KeyValue`, `MultiSelect`, `Sparkline`, `File`, `Image`, `Gravatar`. Relationship fields read a foreign key or relation rather than the attribute, `Icon` has its own display/stored/computed modes, `UiAvatar` derives its initials from `from()` / the attribute directly, and `Repeater` has its own storage modes; `computed()` does not apply to them.

Custom field types that override `resolve()` must read the model through `$this->resolveAttribute($model, $attribute)` instead of `$model->getAttribute()`, otherwise `computed()` is silently ineffective for that type. A type that only needs to look at the stored value (for example to check it) should override `inspectResolvedValue(Model $model, string $attribute, mixed $value): void` instead: `Field::resolve()` calls it with the value it just read, before `resolveUsing()`, so the model is read once and an accessor or a `computed()` callback does not run twice per row.

Without `computed()`, the rule stands: a field's attribute must be a real attribute (column, cast, accessor or loaded relation) and must not share its name with a model method.

### Component Override

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `component` | `component(string $key): static` | `$this` | Override the React component used to render this field. |
| `getComponentKey` | `getComponentKey(): ?string` | `?string` | Get custom component key (null = use default). |

### Metadata

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `withMeta` | `withMeta(array $meta): static` | `$this` | Merge arbitrary key-value metadata into the field descriptor. |

### Index Table Column Width

Controls how the field renders as a column in the index table. See [resources.md](https://getmartis.com/docs/core/resources#tablelayout) for the Resource-level `tableLayout()` switch.

| Method | Signature | Description |
| --- | --- | --- |
| `width` | `width(string $value): static` | Fix the column width (e.g. `"80px"`, `"10rem"`). |
| `minWidth` | `minWidth(string $value): static` | Minimum width. Useful on title columns that would otherwise collapse. |
| `maxWidth` | `maxWidth(string $value): static` | Maximum width. Pair with `truncate()` on URL / email columns. |
| `truncate` | `truncate(bool $value = true): static` | Clip overflow with an ellipsis. Call with `false` to cancel a type default. |
| `textAlign` | `textAlign(string $align): static` | Align the column header and cells: `left`, `center` or `right` (the Nova method). The sort icon stays beside the label, and a right-aligned column uses tabular figures so digits line up. Opt-in: without the call the column keeps today's alignment. Any other value throws `InvalidArgumentException` naming it. v2.23.0+. |
| `hideBelow` | `hideBelow(string $breakpoint): static` | Hide the column (header and cells) on the index when the viewport is narrower than the breakpoint: `sm` (640px), `md` (768px), `lg` (1024px) or `xl` (1280px). A pure CSS rule on the viewport width, so `hideBelow('md')` hides the column below 768px. The field stays on the detail page and in forms. Any other value throws `InvalidArgumentException` naming it. v2.23.0+. |

`textAlign` and `hideBelow` apply to the resource index and lenses. The relationship tables on a detail page do not read the column metadata.

Type defaults (applied automatically unless overridden):

| Field | Default |
| --- | --- |
| `Id` | `width 80px` |
| `Email`, `Url` | `maxWidth 280px` + `truncate` |
| `Boolean`, `Status`, `Badge` | `width 120px` |
| `Date`, `DateTime` | `width 140px` |
| Column matching `titleAttribute()` | `minWidth 220px` |

All defaults can be disabled globally via `config('martis.index.column_defaults', false)` (env `MARTIS_INDEX_COLUMN_DEFAULTS`). Explicit per-field calls still apply. See [resources.md](https://getmartis.com/docs/core/resources#opting-out-of-the-type-default-heuristics) for details.

### Serialization

| Method | Signature | Description |
| --- | --- | --- |
| `toArray` | `toArray(): array` | Serialize field to array for JSON API. Includes: `attribute`, `label`, `type`, `nullable`, `readonly`, `required`, `sortable`, `searchable`, `showOnIndex`, `showOnDetail`, `showOnForms`, `rules`, `component`, plus `extraAttributes()` and `meta`. |

## Tooltips (Martis differential)

⭐ **Martis-exclusive.** In addition to `help()` (plain text under the input), Martis adds **label tooltips** as a separate channel so authors can surface contextual guidance without committing valuable real estate to permanent inline text.

### Why it matters

- `help()` is always visible. It costs vertical space on every form render, for every user, regardless of how long they've been using the resource.
- A **tooltip** is opt-in: the hint shows only when the user hovers the discreet `(?)` icon next to the label. Experienced users never see it; new users have it one hover away.
- HTML support means you can pack **rich, multi-line guidance** (lists, bold, line breaks) into a single call without bloating the form.

### API

```
Text::make('name', 'Full name')
    ->tooltip('<strong>Full legal name</strong>.<br />Examples:<br />• John Smith<br />• Ana Pereira<br /><br /><em>Avoid abbreviations.</em>');
```

| Signature | Notes |
| --- | --- |
| `tooltip(?string $text): static` | Sets the tooltip. Pass `null` to clear. |
| `getTooltip(): ?string` | Returns the current tooltip text (or `null`). |

The tooltip string ships to the frontend in the serialized field under the key `tooltip` and is rendered by the global `MartisTooltip` component behind the `(?)` label icon on any form renderer (Panel, Section, TabGroup, ResourceCreate, ResourceUpdate) **and** on detail labels rendered inside Sections/TabGroups.

### HTML support

The label renderer registers its `(?)` icon as a trigger that may show HTML (`htmlTooltip()`, see [Tooltip Standard](https://getmartis.com/docs/customization/components#tooltip-standard-primereact)), which makes the global `MartisTooltip` provider render the text as HTML; any other `data-pr-tooltip` trigger keeps the default plain-text escape, whatever attributes it carries (the metric `help()` tooltip and an extension's own triggers register the same way). Allowed markup: any inline HTML (`<br />`, `<strong>`, `<em>`, `<ul>`/`<li>`, `<code>`, `<a>`). The markup is sanitised before it is shown (scripts, event handlers, unsafe URLs, `data-*` attributes, the `style` attribute and element, `id`, `name`, forms and form controls are removed), but the author is still responsible for producing it and never puts user or record data in it; prefer localised strings from `__()` / i18n dictionaries to keep content reviewable.

### When to use tooltip() vs help()

| Situation | Use |
| --- | --- |
| Short, essential instruction that every user should read (e.g. "Must be unique") | `help()` |
| Long-form, rarely-needed context (examples, edge cases, links to docs) | `tooltip()` |
| Multi-line rich guidance (bullet lists, bold headings) | `tooltip()` |
| Validation hint that depends on input state | `help()` |

Both can coexist on the same field: `->help('Must be unique')->tooltip('<strong>Uniqueness rules</strong><br />...')`.

### Frontend behaviour

- The `(?)` icon uses the muted text colour so it reads as a quiet affordance.
- Hover delay is **500 ms** — long enough that a cursor skimming the form doesn't flash tooltips, short enough that intentional hover feels responsive.
- Both channels wrap. The bubble is shrink-to-fit up to 360 px (never wider than the viewport minus 16 px): a short label stays on one line, a sentence wraps inside the bubble, and a long unbroken token (a URL, an id) breaks inside it (`overflow-wrap: anywhere`) instead of running past its edge. Plain text renders at 11 px with a tight padding; HTML content at 12 px with a 220 px minimum width so paragraphs read as prose. Since v1.38.0; before, a plain tooltip was pinned to one line in a 300 px box and a long one spilled out of the bubble.
- Plain text stays plain whatever its length: a sentence in `data-pr-tooltip` wraps without the HTML opt-in, and markup in it renders literally. Use `htmlTooltip()` only for content that needs markup.
- Position respects the trigger's `data-pr-position` (defaults to `top`). The bubble is measured first, flips to the opposite side when the requested one has no room for it (a `left` / `right` bubble with room on neither side goes above or below), and is kept inside the viewport with an 8 px margin; the arrow moves along the edge so it keeps pointing at the trigger. While it is open the bubble follows its trigger when the page or a container scrolls, and closes once the trigger leaves the viewport (v1.38.0+).

### Why NOT a Tooltip field class

We deliberately rejected adding a `Tooltip::make()` field. Reasons:

1. A `Field` represents a **column or value**. A pure tooltip has nothing to read, write, validate, or serialise — it breaks the contract.
2. Tooltips are a **presentation concern that applies to fields**, not a field type of their own. Inverting that relationship forces authors to position an empty "tooltip field" inside the form, competing with real fields for `colSpan` and layout placement.
3. The `->tooltip()` modifier is **universal**: it applies to every existing and future field without touching the UI layer.

If you need a hint unrelated to any single field (e.g. a section intro), use `Panel::make('', [])->description(...)` or `Section::make(...)->description(...)` — not a field.

## Field Types

### Id

**Type identifier:** `id` **Extends:** `Field` **File:** `src/Fields/Id.php`

Auto-incrementing primary key. Read-only, hidden from forms, sortable by default.

```
Id::make()           // defaults: attribute='id', label='ID'
Id::make('uuid')     // custom attribute
```

**Default overrides:**

- `readonly = true`
- `showOnForms = false`
- `sortable = true`

**Specific methods:** None (all from base).

### Text

**Type identifier:** `text` **Extends:** `Field` **File:** `src/Fields/Text.php`

Single-line text input. Renders as `<input type="text">`.

```
Text::make('title')
    ->sortable()
    ->searchable()
    ->required()
    ->placeholder('Enter title...')
```

**Specific methods:** None (all from base).

### Textarea

**Type identifier:** `textarea` **Extends:** `Field` **File:** `src/Fields/Textarea.php`

Multi-line text area. Renders as `<textarea>`. It opens at its row count and grows to fit a longer text, and the form or the modal body scrolls.

```
Textarea::make('body', 'Content')
    ->rows(10)
    ->nullable()
    ->hideFromIndex()
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `rows` | `rows(int $rows): static` | `$this` | Set the row count the field opens at. | `5` |
| `getRows` | `getRows(): int` | `int` | Get row count. | — |

**Extra attributes:** `rows`

### Number

**Type identifier:** `number` **Extends:** `Field` **File:** `src/Fields/Number.php`

Numeric input. Renders as `<input type="number">`.

```
Number::make('price', 'Price')
    ->min(0)
    ->max(9999)
    ->step(0.01)
    ->integer()
    ->nullable()
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `min` | `min(int\|float $min): static` | `$this` | Set minimum value (adds `min:N` validation rule). | `null` |
| `max` | `max(int\|float $max): static` | `$this` | Set maximum value (adds `max:N` validation rule). | `null` |
| `step` | `step(int\|float $step): static` | `$this` | Set stepping interval. | `null` |
| `integer` | `integer(): static` | `$this` | Enforce integer validation. | — |

**Extra attributes:** `min`, `max`, `step` (only non-null values)

**Input:** the form input takes the decimal separator of the user's Martis locale (v2.1.0). The index and detail pages show the value as stored.

### Boolean

**Type identifier:** `boolean` **Extends:** `Field` **File:** `src/Fields/Boolean.php`

Toggle/checkbox field. Casts values to strict boolean.

```
Boolean::make('is_active', 'Active')
    ->trueLabel('Enabled')
    ->falseLabel('Disabled')
    ->sortable()
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `trueLabel` | `trueLabel(string $label): static` | `$this` | Label for true value on index/detail. | `__('martis::messages.yes')` |
| `falseLabel` | `falseLabel(string $label): static` | `$this` | Label for false value on index/detail. | `__('martis::messages.no')` |

**Overrides:** `resolve()` casts to `(bool)`, `fill()` casts incoming to `(bool)`. **Extra attributes:** `trueLabel`, `falseLabel`

### BooleanGroup

**Type identifier:** `boolean_group` **Extends:** `Field` **File:** `src/Fields/BooleanGroup.php`

Map of named boolean flags stored as JSON. Ideal for permission grids, feature gates, notification preferences.

```
BooleanGroup::make('permissions')
    ->options([
        'clients.view' => 'View clients',
        'clients.edit' => 'Edit clients',
        'billing.view' => 'View billing',
    ])
    ->grouped([
        'Clients' => ['clients.view', 'clients.edit'],
        'Billing' => ['billing.view'],
    ])
    ->requireAny();
```

| Method | Description |
| --- | --- |
| `options(array\|Closure)` | Flag key → label map. Accepts a closure that resolves at render time. See [Closure-aware setters](https://getmartis.com/docs/core/fields#closure-aware-setters). |
| `labels(array)` | Override option labels with translations |
| `grouped(array)` ⭐ | Organise options into collapsible sections (`['Title' => ['key1','key2']]`) |
| `hideFalseValues(bool)` / `hideTrueValues(bool)` | Display filter for the read-only view |
| `noValueText(string)` | Placeholder when every flag is off and `hideFalseValues` is on |
| `minChecked(int)` ⭐ | Minimum number of flags that must be on — shown as a live UI counter |
| `maxChecked(int)` ⭐ | Maximum number of flags that can be on — disables extra checkboxes live |
| `requireAny()` ⭐ | Sugar for `minChecked(1)` |
| `requireAll()` ⭐ | Sugar for `minChecked(count(options))` |

The server enforces the limits on the set `fill()` stores (v2.4.0): the submitted keys the field offers, each read as a boolean the way `fill()` reads it (`true`, `1`, `'1'`, `'true'`, `'on'`, `'yes'` are on, in any letter case), so a key the options do not name never counts toward either limit and a truthy spelling never escapes `maxChecked()`.

> ⚠️ When `options()` is given a closure, `requireAll()` cannot pre-compute its target at field declaration time — the closure has not run yet. Pair the closure form with `minChecked(int)` directly, or use `requireAny()` (always `1`).

**Storage format:** `{"flag":true,"other":false}` on a plain column, or the map itself through an `array` / `json` cast. **Overrides:** `resolve()` decodes a JSON string or array to the normalised `{key: bool}` map; `fill()` decodes a JSON string to the map (the multipart path, or a direct call) and writes only the flags `options()` offers (below).

**What a write stores.** The options a user is offered are the set of flags that user may change (an `options()` closure can scope them to the authenticated user), so `fill()` projects the submitted map onto them (since v2.4.0):

- a submitted key the options do not name is ignored, so a crafted request cannot store `{"admin": true}` next to the flags it was shown;
- each offered value becomes a boolean (`true`, `1`, `'1'`, `'true'`, `'on'`, `'yes'` are on, everything else is off), and an offered flag the submission leaves out is off;
- a flag already stored that the user was **not** offered keeps its stored value, so an editor who sees a subset of the flags can neither switch on a flag they were not shown nor erase one an administrator set;
- an empty value (`null`, `''`) switches every offered flag off and stores `null` when no hidden flag is left to keep;
- a `fillUsing()` callback receives the offered flags only (the same projection), and a readonly or `computed()` field writes nothing, as for every field.

On a column without an `array` / `json` cast the map is stored as a JSON string, as for `KeyValue` and `MultiSelect` (see [Structured values and Eloquent casts](https://getmartis.com/docs/core/fields#structured-values-and-eloquent-casts)).

**⭐ Martis differentials:** grouped sections, min/max live counter, `requireAny/All` presets.

### Avatar

**Type identifier:** `avatar` **Extends:** `Image` **File:** `src/Fields/Avatar.php`

Image upload specialised for profile pictures. Inherits every upload helper from `Image` (`disk`, `storagePath`, `maxSize`, `thumbnail`, …).

**Zero configuration needed for the empty state** — when a record has no upload AND the developer didn't declare an explicit `fallback()`, the field renders coloured initials inline on one of the theme's 16 avatar tokens: the same letters and colour as the Topbar and profile avatars of a user with that name. No external service call, no extra DB column, no boilerplate closure.

```
// One-liner: uploaded photo when present, coloured initials inline otherwise.
Avatar::make('avatar_path')
    ->disk('public')
    ->storagePath('team-avatars')
    ->maxSize(2048)
    ->circle()
    ->colorFrom('brand_color'); // optional: use a brand attribute instead of the hash palette
```

**Resolution priority (per row):**

| # | Condition | Output |
| --- | --- | --- |
| 1 | Stored file exists | `<img src={uploaded_url}>` |
| 2 | Developer set `fallback($url \| Closure)` | `<img src={fallback}>` |
| 3 | Seed has initials | Inline coloured initials — no external request |
| 4 | Seed exists but produces no initials | Muted user-glyph chip (`.martis-avatar-fallback`) |
| 5 | Nothing at all | em-dash placeholder |

| Method | Description |
| --- | --- |
| `shape(AvatarShape)` | Typed enum: `Circle` (default), `Rounded`, `Squared` |
| `circle()` / `rounded()` / `squared()` | Convenience shortcuts |
| `fallback($url \| Closure)` ⭐ | Override the default inline initials with a custom URL (static or per-row) — opt-in, usually unnecessary |
| `initialsFrom(string)` ⭐ | Seed attribute for initials + palette (default: `name`) |
| `colorFrom(string)` ⭐ | Pull the initials background from a model attribute (e.g. `brand_color`) |
| `initials(Closure)` ⭐ | Custom initials computation. Closure receives `($seed, $model)` |

**Initials payload.** When the field falls back to initials, its value carries `isInitialsFallback: true` and:

| Key | Value |
| --- | --- |
| `initials` | The letters: the first letter of the seed's first and last words, or your `initials()` closure's result. |
| `palette` | The slot (1 to 16) of the theme's `--martis-avatar-N` tokens the circle is painted with, or `null` when `colorFrom()` gave the colour. |
| `color` | The literal colour: the `colorFrom()` value, or the slot's colour in the built-in theme. |
| `seed` | The seed attribute's value. |

A custom component that reads the payload paints `var(--martis-avatar-{palette})` when `palette` is set, so it follows the theme like the built-in circle, and `color` otherwise.

**⭐ Martis differentials:**

- **Zero-config inline initials fallback** — no external service, no extra closures, works out of the box.
- **Deterministic 16-hue palette** declared as `--martis-avatar-1..16` tokens, stable across light/dark themes and redefinable by a theme. The server picks one from a hash of the seed (`name`, `email`, `slug`) — two users with the same name always get the same colour.
- **Empty-seed glyph** — when the record has no name/initials, the field renders a muted user icon inside `.martis-avatar-fallback` instead of a bare em-dash.
- Per-row Closure-aware `fallback()` when you *do* want a custom URL.
- Typed `AvatarShape` enum instead of a boolean `rounded()`.
- Initials and palette shared with `UiAvatar`, the Topbar and the profile page through `Martis\Support\Initials` (the fields reach it via the [`ResolvesInitialsPayload`](https://github.com/Real-Edge-FX/martis-package/blob/main/src/Fields/Concerns/ResolvesInitialsPayload.php) trait).

### UiAvatar

**Type identifier:** `ui_avatar` **Extends:** `Field` **File:** `src/Fields/UiAvatar.php`

Display-only initials pill. Differs from `Avatar` in one key way: **it never takes an upload** — the value is computed entirely from the model.

**When to pick `UiAvatar` over `Avatar`:**

| Your resource… | Use |
| --- | --- |
| has a photo column and you want users to upload | `Avatar` (initials inline when empty come for free) |
| has no photo column at all and never will | `UiAvatar` |
| is read-only / system-generated and an upload input would be noise | `UiAvatar` |

```
UiAvatar::make('avatar_initials')
    ->from('name')
    ->colorFrom('brand_color')
    ->circle();
```

| Method | Description |
| --- | --- |
| `from(string)` | Attribute used as the seed (default: field's own attribute) |
| `shape(AvatarShape)` / `circle()` / `rounded()` / `squared()` | Match the visual style of `Avatar` |
| `colorFrom(string)` ⭐ | Pull background colour from a model attribute (brand colour) |
| `initials(Closure)` ⭐ | Custom initials computation. Closure receives `($seed, $model)` |

The value has the same `initials`, `palette`, `color` and `seed` keys as the [Avatar initials payload](https://getmartis.com/docs/core/fields#avatar), plus `shape`.

**⭐ Martis differentials:** **deterministic 16-slot palette from seed hash** (same name → same colour, zero DB; a theme recolours it through the `--martis-avatar-N` tokens), `colorFrom('attribute')` override, custom-initials closure, decoupled seed via `from()`. Computed with the record, painted inline: no external service call.

### Audio

**Type identifier:** `audio` **Extends:** `File` **File:** `src/Fields/Audio.php`

File upload specialised for audio clips. Renders a fully custom on-brand player (accent play/pause button, waveform OR progress track, mono current/total timestamps, optional download affordance) and a drag-and-drop dropzone empty state. The native `<audio controls>` element is intentionally bypassed so the look stays consistent under both light and dark themes.

```
Audio::make('intro_audio_path')
    ->disk('public')
    ->storagePath('team-audio')
    ->maxSize(10240)
    ->downloadable(false);
```

| Method | Description |
| --- | --- |
| `waveform(bool)` ⭐ | Toggle the canvas waveform (default `true`). When false the player falls back to a thin progress track. |
| `downloadable(bool)` | Toggle the download icon next to the player. |
| `acceptedTypes(array)` | Override the default `mp3/wav/ogg/m4a/flac/aac` allow-list. |

**Empty state.** When no file is attached, the input renders a dashed dropzone with a Phosphor `MusicNote` icon, the `audio_empty_title` and `audio_empty_hint` copy, and a "Choose file" CTA. Drag-and-drop is supported.

**i18n keys** (in `messages.php`, all three locales):

`audio_empty_title`, `audio_empty_hint`, `audio_browse`, `audio_replace`, `audio_remove`, `audio_play`, `audio_pause`, `audio_download`.

**⭐ Martis differentials:** client-side canvas waveform via Web Audio API (no server rendering), custom player chrome that follows the design tokens, drag-and-drop dropzone, `downloadable(bool)` toggle.

### Select

**Type identifier:** `select` **Extends:** `Field` **File:** `src/Fields/Select.php`

> **Version note.** Martis v2.0 and later read `options()` as `[value => label]`, Nova's order; Martis v1.x read `[label => value]`. On v1.x, flip the arrays in the examples below, or upgrade: see [Upgrading](https://getmartis.com/docs/getting-started/upgrading).

Dropdown select with predefined options.

```
Select::make('status')
    ->options([
        'draft'     => 'Draft',
        'published' => 'Published',
        'archived'  => 'Archived',
    ])
    ->nullable()
```

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `options` | `options(iterable\|Arrayable\|string\|Closure $options): static` | `$this` | Set options in Nova's order, `[value => label]`: the key is stored, the value is shown. Also takes a list (stores 0, 1, 2…, as in Nova), grouped options (`[value => ['label' => ..., 'group' => ...]]`), a Collection or any other iterable (as in Nova), an enum class (Martis extension), or a closure that resolves at render time (perfect for DB-backed lists). See [Option order](https://getmartis.com/docs/core/fields#option-order) and [Closure-aware setters](https://getmartis.com/docs/core/fields#closure-aware-setters). |
| `withoutOptionOrderWarnings` | `withoutOptionOrderWarnings(): static` | `$this` | Silence the stored-label and list-shift warnings below, and the `searchOptionsUsing()` list warning, for this field (Martis extension). Use it when a stored value that looks like stale v1.x data is actually correct, for example a Nova rating that stores `0..4` on purpose and shows `"1".."5"`. |
| `displayUsingLabels` | `displayUsingLabels(): static` | `$this` | Render the option label on index and detail (default behaviour). Symmetric with `MultiSelect::displayUsingLabels()` for code that handles both fields generically. |
| `displayUsingValues` | `displayUsingValues(): static` | `$this` | Render the raw stored value on index and detail. Useful when the value is itself meaningful (ISO codes, slugs) and the label is just a humanised alias. |
| `isDisplayingLabels` | `isDisplayingLabels(): bool` | `bool` | Whether the field currently renders labels (true) or raw values (false). |
| `getOptions` | `getOptions(): array` | `array` | Get normalized options `[{label, value, group?}]` (resolves the closure if one was set). |
| `searchableOptions` | `searchableOptions(bool $value = true): static` | `$this` | Render a search box above the option list so the user can narrow it by label or value (v1.37.0). Not the same as `searchable()`, which makes the column part of the resource search. |
| `hasSearchableOptions` | `hasSearchableOptions(): bool` | `bool` | Whether the option list renders with a search box. |
| `allowCustomValues` | `allowCustomValues(bool $value = true): static` | `$this` | Accept a typed value that is not one of the options (v1.37.0). The stored value may then fall outside `getOptions()`; index and detail render it raw. Validation against the list stays yours (`Rule::in`, or `validateAgainstOptions()` when the list must be closed after all). |
| `validateAgainstOptions` | `validateAgainstOptions(bool $value = true): static` | `$this` | Reject, on the server, a value that is not one of the options (v2.4.0, Martis extension). Off by default: a `Select` has never validated against its options. See [Validating against the options](https://getmartis.com/docs/core/fields#validating-against-the-options). |
| `validatesAgainstOptions` | `validatesAgainstOptions(): bool` | `bool` | Whether the field rejects a value that is not one of its options. |
| `allowsCustomValues` | `allowsCustomValues(): bool` | `bool` | Whether the control accepts values outside the option list. |
| `searchOptionsUsing` | `searchOptionsUsing(Closure $resolver): static` | `$this` | Search the options on the server as the user types (v1.37.0). The closure receives `(string $term, ?Request $request)` and returns the same shapes `options()` accepts. Implies `searchableOptions()`. Resource forms and Tools implementing `ProvidesFields` only; see below. |
| `hasRemoteOptionsSearch` | `hasRemoteOptionsSearch(): bool` | `bool` | Whether a server-side resolver is registered. |
| `searchOptions` | `searchOptions(string $term, ?Request $request = null): array` | `array` | Run the server-side resolver and normalise the result to `[{label, value, group?}]`. Empty list without a resolver. |

**Extra attributes:** `options`, `displayLabels`, `searchableOptions`, `allowCustomValues`, `remoteOptionsSearch`

```
// Static options: [value => label]
Select::make('status')->options(['draft' => 'Draft', 'published' => 'Published']);
 
// Translated labels: the stored value stays fixed while the label is translated
Select::make('plan')->options([
    'free' => __('plans.free'),
    'pro'  => __('plans.pro'),
]);
 
// Database-backed options resolved at render time: stores the id
Select::make('owner_id')->options(fn () => User::query()->pluck('name', 'id')->all());
 
// Grouped options, in Nova's format
Select::make('size')->options([
    'MS' => ['label' => 'Small', 'group' => 'Men Sizes'],
    'WS' => ['label' => 'Small', 'group' => 'Women Sizes'],
]);
 
// Show the raw ISO code on the index column instead of the country name
Select::make('country_code')
    ->options(['PT' => 'Portugal', 'GB' => 'United Kingdom'])
    ->displayUsingValues();
```

#### Option order

`options()` reads `[value => label]`, the order Nova uses for `Select`, `MultiSelect` and `BooleanGroup`: the key is what the field stores, the value is what the user sees. So `pluck('name', 'id')` stores the id, and two records with the same name stay two options. Before v2.0.0 Martis read the array label first; see [Upgrading](https://getmartis.com/docs/getting-started/upgrading).

- **A list is a map keyed 0, 1, 2…**, so `options(['Small', 'Large'])` stores `0` and `1`, as in Nova. When each value is its own label, pass `array_combine($values, $values)`. A list of numbers is the trap: `options([1, 2, 3])` stores `0`, `1` and `2`, so `options(range(1, 12))` for months stores 0 for January.
- **Grouped options** map a value to `['label' => ..., 'group' => ...]`. Any other array value throws an `InvalidArgumentException` that names the field and the option (the pre-v2 `['Group' => ['Label' => 'value']]` format included). The `Select` dropdown shows each group under its heading, in the order the groups first appear, with ungrouped options on top; the `MultiSelect` list keeps groups and ungrouped options in the order they first appear.
- **An enum class** (`options(Status::class)`, Martis extension) stores the case value of a backed enum, or the case name of a pure one, labelled with the headline of the case name.
- **A Collection**, or any other iterable or `Arrayable`, reads like an array, as in Nova: pass it straight in (`options(User::query()->pluck('name', 'id'))`) or return it from a closure (`fn () => User::query()->pluck('name', 'id')`), without `->all()`. Passed straight in, the query runs when the field is built, on every request that builds it; from a closure, only when the options are read.
- **`searchOptionsUsing()`** returns the same shapes, in the same order, a Collection included. A non-empty list is keyed 0, 1, 2, so the field would store a position in that search's results, which changes with the term: it logs a warning, at most once per field per request.
- **Filters are the exception, as in Nova:** a filter's `options(Request $request)` returns `[label => value]`. See [Filters](https://getmartis.com/docs/core/filters).

**Stored labels (Martis extension).** Reading a record whose stored value matches the label of a static option and the value of none logs a warning, in production too, at most once per request for each model class and field: the options array is still written label first, or the record was saved while it was, and saving it again would store the wrong value. For options given as a list, a stored value that is the label of another option warns too (`options([1, 2, 3])` holding a 1 saved under v1.x, which now shows as "2"). The check reads the stored value, before `resolveUsing()`. It skips options from a closure (so it never runs a query of its own), computed fields, and a `Select` with `allowCustomValues()`, where a typed label is a legitimate value. Both warnings assume the stored value is stale v1.x data, but a field can legitimately store a valid 0-based position that reads as another option's label, for example a Nova rating that stores `0..4` on purpose and shows `"1".."5"`: call `withoutOptionOrderWarnings()` on the field (Martis extension) to silence both warnings for it once the array is confirmed right.

#### Validating against the options

**A `Select` does not check, on the server, that the submitted value is one of its options.** The dropdown only offers them, but a request can carry any value, and the field writes it as sent: a forged `role_id`, a status the list never showed, an option the picker hides from this user. Nova's `Select` behaves the same, so `options()` alone is a UI constraint, not a rule.

When the list must be closed, opt in:

```
Select::make('status')
    ->options(['draft' => 'Draft', 'live' => 'Live'])
    ->validateAgainstOptions();
 
// A closure of options: the query runs when a request is validated, not while the schema is built
Select::make('role_id')
    ->options(fn () => Role::query()->whereNull('provider_group_name')->pluck('name', 'id'))
    ->required()
    ->validateAgainstOptions();
```

- **One rule, over `getOptions()`**, with the options as they are when the request is validated. A value fails with `martis::validation.not_in_options` (`The selected Status is invalid.`, translated in `en`, `pt_PT` and `pt_BR`, with the field's label) as a `422` on the field, before anything is written. It applies wherever the field's rules do: the resource's create and update, the inline creates, and the fields of an [Action](https://getmartis.com/docs/core/actions) modal.
- **By value, not by label,** and only as a string or an integer: an option keyed `7` accepts `7` and `'7'`, not `'07'`, `'7.0'`, `7.0`, `true` or an array. Grouped options count by their value; a list (`['Small', 'Large']`) accepts `0` and `1`; an enum class accepts its case values.
- **An empty value is left to `required()` and `nullable()`**, as for every other field. On create `required()` refuses it. On update every field's `required` is dropped (a form that leaves a field alone, a `Password` for one, sends it empty), so a request that names a required Select empty on update is not refused by it: the options rule has no value to judge, and the field is cleared. When an update must never clear it, add a rule object of your own that survives the drop: `->updateRules([Rule::requiredIf(true)])`.
- **Not for a list that is only a first page.** A field with `searchOptionsUsing()` loads the rest of its options from the server, so a value the search returns is not in `getOptions()` and would be rejected: validate that field with a rule of your own. With `allowCustomValues()` the closed list wins and a typed value is rejected, so pick one of the two.
- A rule that depends on who asks (the roles this user may hand out) belongs in the `options()` closure, which receives the request: the same list then feeds the dropdown and the rule. The scaffolded `InviteUser` and `BulkAssignRole` actions do exactly that (see [Invitations](https://getmartis.com/docs/auth/invitations#the-invite-role-picker) and [Roles](https://getmartis.com/docs/auth/roles)).

`MultiSelect::validateAgainstOptions()` is the same option for a selection: every selected value must be an option (one stray value fails the whole field), and anything that is not a list of strings and integers fails too.

**Clear (X) icon.** On a `nullable()` select, the clear icon appears only once a value is selected — an empty select has nothing to clear, so no X shows on the placeholder state.

**Searchable options, custom values and server-side search (v1.37.0).**

```
// Long list: search box over the options (matches label or value)
Select::make('currency')->options(Currency::all())->searchableOptions();
 
// Pick from the list or type a value the list does not carry
Select::make('model')->options(['gpt-4o' => 'gpt-4o'])->searchableOptions()->allowCustomValues();
 
// Very long list: keep the first page in options(), search the rest on the server
Select::make('model')
    ->options(fn () => ModelCatalog::top(50))
    // Both closures return [value => label]; a list would store positions.
    ->searchOptionsUsing(fn (string $term, ?Request $request) => ModelCatalog::search($term, limit: 50));
```

- **`searchableOptions()`** renders PrimeReact's filter box inside the panel; filtering happens in the browser over the serialised `options`. Coming from Nova: Nova's `Select::searchable()` is this method. In Martis, `searchable()` on any field (including `Select`) means "the column takes part in the resource search", and it is serialised as `searchable`; the option search box is a separate flag, `searchableOptions`.
- **`allowCustomValues()`** makes the control editable. A typed value reaches the form state on every keystroke, exactly like a `Text` field, and is stored as-is; `SelectFieldDisplay` renders a value with no matching option as the raw string. The field adds no `Rule::in` validation, so add one yourself when the list must be closed after all (`validateAgainstOptions()` is that rule, but it rejects a typed value, which defeats this flag: use one or the other).
- **`searchOptionsUsing()`** moves the search to the server. When the panel opens the frontend calls the field-options endpoint with an empty term (return your "first page" there), then again after each typing pause (300 ms), aborting the previous request; the initial `options()` list is shown until the first response lands. The endpoint is derived from the form's scope: `GET /api/resources/{resource}/fields/{attribute}/options?context=create|update&id=<record>` for a Resource form (gated on the create ability, or on the update ability with the edited record bound, like `sync-field`) and `GET /api/tools/{uriKey}/fields/{attribute}/options` for a Tool implementing `ProvidesFields` whose form passes `toolKey` (see [Tool fields](https://getmartis.com/docs/customization/tool-fields#server-side-option-search)). A select in a `Repeater` row asks the same endpoints with the form's own context and names its row (`&repeater={attribute}&repeatable={type}`), where the server finds it (v1.38.0+; see [Repeater → Relation pickers and remote selects in rows](https://getmartis.com/docs/core/repeater#relation-pickers-and-remote-selects-in-rows)). Outside those two scopes (Action modals, a relationship's pivot fields, a frontend-only Tool form) the select falls back to local filtering over `options()`. The server decides what matches: results are shown as returned, never re-filtered in the browser. A stored value that is not in the current list is still shown in the control and can be cleared. An option picked from the results keeps its label in the control once the panel closes, a `''` ("None") option included. Index and detail read labels from `options()` only, so a value that only the search returns shows raw there, and a `''` shows the dash: list such an option in `options()` too when it must show its label. The term is trimmed and clamped to 255 characters before it reaches your closure. On a Resource form the select is looked up on the form of the context only: `fieldsForUpdate()`, or `fieldsForCreate()` and then the inline-create modal's `fieldsForInlineCreate()` (v1.38.0: a select declared on the inline-create form alone used to answer 422).

**Filter variant + custom class (v1.29.0).** When rendering a select through the runtime `FieldInput` (e.g. a filter bar inside a [Tool](https://getmartis.com/docs/customization/tool-fields)), the frontend honours two extra keys on the field definition:

- `variant: 'filter'` — adds the compact `martis-filter-dropdown` class (the same look Martis's native resource filters use).
- `className: '...'` — forwarded verbatim onto the Dropdown root, so you can layer your own utility classes.
- `searchableOptions: true` / `allowCustomValues: true` / `remoteOptionsSearch: true` (v1.37.0) — the three Select flags above, usable from a frontend-only definition too. `remoteOptionsSearch` only does something when the form has a `resourceKey` or a `toolKey`.

```
// FieldDefinition passed to runtime.FieldInput
{ type: 'select', attribute: 'status', label: 'Status', variant: 'filter', className: 'w-48', options: [...] }
```

These are frontend-only rendering hints; they have no effect on a plain `Select` field inside a normal Resource form.

### GuardSelect

**Type identifier:** `select` (inherits) **Extends:** `Select` **File:** `src/Fields/GuardSelect.php`

Dropdown of the auth guards configured in the host app's `config/auth.php`. Designed for `guard_name` columns on Spatie Permission / Role tables — most installs have a single guard (`web`), but the field gives the developer a confident UI to pick from instead of typing free text. Shipped in v1.8.0.

```
use Martis\Fields\GuardSelect;
use Martis\Fields\Slug;
 
public function fields(Request $request): array
{
    return [
        Id::make()->sortable(),
        Slug::make('name')
            ->separator('.')
            ->reserved(['*'])
            ->help(__('martis::permissions.name_help'))
            ->required(),
        GuardSelect::make('guard_name')
            ->help(__('martis::permissions.guard_help'))
            ->required(),
    ];
}
```

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `make` | `make(string $attribute, ?string $label = null): static` | `static` | Factory. Auto-wires the guard-list resolver, pulls the default value from `config('auth.defaults.guard')`, marks the field `required()`, and validates submitted values against `config('auth.guards')` keys via `Rule::in(...)`. |
| `only` | `only(array $guardNames): static` | `$this` | Restrict the dropdown to a subset of the configured guards. Useful when a Resource only makes sense against one specific guard (`api` permissions vs `web` permissions managed in separate sections of the admin). Empty strings are filtered out. |

The label and value are always identical — guard names are tokens (`web`, `api`, `sanctum`) and translating them would be misleading. Server-side validation rejects any value not declared in `config/auth.guards`, which prevents Spatie Permission from later crashing on `Role::users()` (`morphedByMany` cannot resolve the User model for an unconfigured guard).

The list is fetched lazily at schema-render time so `config()` is read after the host app has finished booting. The catalog comes from `Martis\Auth\GuardCatalog`, which exposes `available(): list<string>` and `default(): string` for advanced uses.

### MultiSelect

**Type identifier:** `multi_select` **Extends:** `Field` **File:** `src/Fields/MultiSelect.php`

> **Version note.** Martis v2.0 and later read `options()` as `[value => label]`, Nova's order; Martis v1.x read `[label => value]`. On v1.x, flip the arrays in the examples below, or upgrade: see [Upgrading](https://getmartis.com/docs/getting-started/upgrading).

Multi-value select with chips/tags UI. Stores as JSON array.

```
MultiSelect::make('technologies')
    ->options([
        'php'    => ['label' => 'PHP', 'group' => 'Backend'],
        'python' => ['label' => 'Python', 'group' => 'Backend'],
        'react'  => ['label' => 'React', 'group' => 'Frontend'],
        'vue'    => ['label' => 'Vue', 'group' => 'Frontend'],
    ])
    ->displayUsingLabels()
    ->required()
```

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `options` | `options(iterable\|Arrayable\|string\|Closure $options): static` | `$this` | Set options in Nova's order, `[value => label]`, with the same forms as `Select::options()`: a list (0, 1, 2…), **grouped** options (`[value => ['label' => ..., 'group' => ...]]`), an enum class (Martis extension), or a closure that resolves at render time. See [Option order](https://getmartis.com/docs/core/fields#option-order) and [Closure-aware setters](https://getmartis.com/docs/core/fields#closure-aware-setters). |
| `withoutOptionOrderWarnings` | `withoutOptionOrderWarnings(): static` | `$this` | Silence the stored-label and list-shift warnings (and, on `Select`, the `searchOptionsUsing()` list warning) for this field (Martis extension). See the same row on `Select`. |
| `displayUsingLabels` | `displayUsingLabels(): static` | `$this` | Show labels instead of raw values on index/detail. |
| `getOptions` | `getOptions(): array` | `array` | Get normalized options `[{label, value, group?}]` (resolves the closure if one was set). |
| `isDisplayingLabels` | `isDisplayingLabels(): bool` | `bool` | Check if displaying labels. |
| `validateAgainstOptions` | `validateAgainstOptions(bool $value = true): static` | `$this` | Reject, on the server, a selection that holds a value outside the options (v2.4.0, Martis extension). Off by default: like `Select`, a `MultiSelect` never validated against its options. Every selected value must be an option, matched by value as a string or an integer; an empty selection is left to `required()` and `nullable()`. See [Validating against the options](https://getmartis.com/docs/core/fields#validating-against-the-options). |
| `validatesAgainstOptions` | `validatesAgainstOptions(): bool` | `bool` | Whether the field rejects a selection that holds a value outside its options. |

**Storage format:** JSON array, e.g. `["php","react"]` **Overrides:** `resolve()` decodes JSON/array to list; `fill()` writes the list, JSON-encoded unless the attribute carries an `array` / `json` / class cast that serialises it itself (see [Structured values and Eloquent casts](https://getmartis.com/docs/core/fields#structured-values-and-eloquent-casts)). **Extra attributes:** `options`, `displayLabels`

### Date

**Type identifier:** `date` **Extends:** `Field` **File:** `src/Fields/Date.php`

Date picker (date-only by default).

```
Date::make('published_at', 'Published')
    ->withTime()          // enable date+time mode
    ->format('d/m/Y')     // custom display format
    ->nullable()
    ->sortable()
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `withTime` | `withTime(bool $value = true): static` | `$this` | Enable date+time mode (changes format to `Y-m-d H:i:s`). | `false` |
| `format` | `format(string $format): static` | `$this` | Customize display format. | `Y-m-d` |

**Overrides:** `resolve()` normalizes `Carbon`/`DateTime` to formatted string. **Extra attributes:** `withTime`, `displayFormat`

**Display:** the index and detail pages show the date in the user's Martis locale, not the browser's (v2.1.0): `27/09/2026` for `pt_PT`, `9/27/2026` for `en_US`. A value `format()` turned into another string is shown as sent.

### DateTime

**Type identifier:** `datetime` **Extends:** `Date` **File:** `src/Fields/DateTime.php`

Date + time picker. Extends Date; inherits all Date methods.

```
DateTime::make('created_at', 'Created')
    ->hideFromForms()
    ->sortable()
```

**Specific methods:** None (inherits `withTime()`, `format()` from Date).

**Display:** the date and time in the user's Martis locale (v2.1.0).

**Input:** a calendar with a 24-hour time picker. The hour and minute can be typed (digits, then the arrow keys or the up/down buttons to step; values wrap around). Before v2.16.4 they could only be changed with the buttons.

### Email

**Type identifier:** `email` **Extends:** `Text` **File:** `src/Fields/Email.php`

Email input with automatic `email` validation rule.

```
Email::make('email')
    ->sortable()
    ->searchable()
    ->required()
    ->unique(['users', 'email'], 'This email is already in use.')
```

**Overrides:** `buildRules()` appends `'email'` to validation rules. **Specific methods:** None (inherits from Text).

### Password

**Type identifier:** `password` **Extends:** `Field` **File:** `src/Fields/Password.php`

Password field. Hashes value with bcrypt, never exposes hashes.

```
Password::make('password')
    ->nullable()
    ->withStrengthMeter()
```

**Default overrides:**

- `showOnIndex = false`
- `showOnDetail = false`

**Overrides:**

- `resolve()` always returns `null` (never expose password hashes).
- `fill()` hashes with `Hash::make()`. Skips empty/null values (no update if blank). A `fillUsing()` callback takes the write over and receives the plain value (hashing is then its decision); an empty value never reaches it. Its value is never stored in the [action event log](https://getmartis.com/docs/core/actions#action-field-values-in-the-log) either: an action's `Password` field is logged as `******`.

**Specific methods:**

- `withStrengthMeter(bool $enabled = true): static` — ⭐ **Martis extension.** Shows a 0–4 strength meter below the input (length + character-class heuristic). Pairs naturally with `PasswordConfirmation` to share the same UI cue. No extra dependency — zxcvbn-lite is inlined in the React component.

**Declarative complexity requirements (⭐ Martis extension):**

Each requirement method adds a matching Laravel validation rule AND publishes to the frontend so the `showRequirements()` checklist can tick in real time.

- `minLength(int $length): static` — minimum length. Adds `min:N`.
- `requireUppercase(bool $value = true): static` — adds `regex:/\p{Lu}/u`.
- `requireLowercase(bool $value = true): static` — adds `regex:/\p{Ll}/u`.
- `requireNumber(bool $value = true): static` — adds `regex:/\pN/u`.
- `requireSymbol(bool $value = true): static` — adds `regex:/\p{Z}|\p{S}|\p{P}/u`.
- `defaultRules(bool $value = true): static`: validates with the app's `Password::defaults()` and shows its requirements in the checklist (v2.3.0), as Nova's generated User resource validates with `Password::default()`. The server enforces the policy and the field's own requirements together, so the checklist shows the stricter of the two minimums. On a resource that creates and updates users, require it on create and leave it optional on update, as Nova's User resource does: `->creationRules(['required'])->updateRules(['nullable'])`; a blank update keeps the current password.
- `disallowCommonPasswords(bool $value = true): static` — rejects a small inline list (`password`, `qwerty`, `12345…`, `letmein`, `admin`, `welcome`, `abc123`, `iloveyou`) via a closure rule.
- `showRequirements(bool $value = true): static` — opt-in. Renders the ✓/✗ checklist under the strength meter. When every requirement passes, the meter is clamped to at least "Good" (score ≥ 3) so "all checks green" never reads as "Weak".

The uppercase, lowercase, number and symbol checks are those of Laravel's `Password` rule (v2.3.0): an accented capital is uppercase, an accented letter is not a symbol, and lengths count characters.

Example:

```
Password::make('password')
    ->withStrengthMeter()
    ->minLength(10)
    ->requireUppercase()
    ->requireNumber()
    ->requireSymbol()
    ->disallowCommonPasswords()
    ->showRequirements()
```

Or follow the app's policy (v2.3.0):

```
Password::make('password')
    ->defaultRules()
    ->creationRules(['required'])
    ->updateRules(['nullable'])
    ->withStrengthMeter()
    ->showRequirements()
```

### PasswordConfirmation

**Type identifier:** `password_confirmation` **Extends:** `Field` **File:** `src/Fields/PasswordConfirmation.php`

Companion field for a paired `Password`. Never persists to the model — relies on Laravel's `confirmed` rule on the password field for validation.

```
Password::make('password')
    ->creationRules(['required', 'confirmed', 'min:8'])
    ->withStrengthMeter(),
 
PasswordConfirmation::make('password_confirmation')
    ->confirms('password')
```

**Default overrides:**

- `showOnIndex = false`
- `showOnDetail = false`
- `fill()` is a no-op (companion fields never hydrate the model).
- `resolve()` always returns `null`.

**Specific methods:**

- `confirms(string $attribute): static` — Name the paired password attribute (default: `'password'`). Matches Laravel's `confirmed` rule convention.

**⭐ Martis extensions (UI, automatic):**

- Live match indicator — green tick / red cross the moment the two inputs match or diverge.
- Shared strength meter — if the paired `Password` field has `withStrengthMeter()`, the meter is shared.
- Synchronized visibility toggle — eye icons on both inputs mirror each other.

### Slug

**Type identifier:** `slug` **Extends:** `Field` **File:** `src/Fields/Slug.php`

URL-safe identifier auto-generated from a source attribute.

```
Slug::make('slug')
    ->from('title')                                // source attribute
    ->separator('-')                               // default: '-'
    ->reserved(['admin', 'api', 'login'])          // rejected values
    ->lockAfter(fn ($post) => $post->is_published) // freeze after publish
    ->withinIndexScope()                           // probe uniqueness per tenant (see below)
```

**Specific methods:**

- `from(string $attribute): static` — Source attribute to slugify.
- `separator(string $separator): static` — Token separator (default: `'-'`).
- `reserved(array $reserved): static` — ⭐ **Martis extension.** Reject these exact values (system paths). Validation emits `slug_reserved` error; the `/slug-check` endpoint returns `{ reserved: true, suggestion }`.
- `lockAfter(Closure $condition): static` — ⭐ **Martis extension.** Freezes the slug on existing records once the condition holds. `Slug::fill()` becomes a no-op in that case — SEO protection.
- `withinIndexScope(bool $within = true): static` — ⭐ **Martis extension.** Run the slug-check uniqueness probe through the resource's `scopes()` and `indexQuery()` instead of the whole table. Enable it when uniqueness is per tenant or per owner (a composite unique index such as `tenant_id, slug`), so the check answers for the records the user can list and never reveals that a slug exists in another scope. See [Live collision detection](https://getmartis.com/docs/core/fields#slug-collision-detection).
- `badgeVariant(string $variant): static` — Read-only display badge variant. Accepts `'default'` (alias `'muted'`), `'accent'`, `'success'`, `'warning'`, `'danger'`, `'custom'`. Unknown values fall back to `'default'`.
- `badgeAccent(): static` — Sugar for `badgeVariant('accent')`. Reads cleanly when the slug IS the row identity (Permission name, Role name).
- `badgeColor(string $color): static` — Custom CSS colour for the badge. Accepts any browser-recognised colour (hex, `rgb()`, `hsl()`, `oklch()`, named). The frontend tints the background as a 14% mix with the surface so it stays subtle in both themes; the foreground uses the colour verbatim. Implies `badgeVariant('custom')`.
- `generate(string $value): string` — Unicode-safe slugify ("São Paulo" → "sao-paulo"). Exposed for tests/controllers.
- `isLockedFor(?Model $model): bool` — Query the lock condition directly.

**⭐ Martis extensions (UI, automatic):**

- **Live preview** — the React input regenerates the slug as the user types in the source field (i18n-aware transliteration), until the slug is edited by hand. On a create form, a replicated record included, the slug follows the source from the source's first change, as in Nova: the slug the form opens with, copied or empty, stays until then (v1.38.0+). On an edit form (the update page or the update drawer) a stored slug counts as set: changing the source leaves it alone, so renaming a record does not silently change its URL, and an empty stored slug follows the source at once. Edit the slug directly, or clear it (a `nullable()` slug shows a clear button) to regenerate it from the source and follow it again. A slug the user empties by hand stays empty while the source has text, and follows the source again once the source is empty too, so the next record's slug follows its title after "Create & add another" (v1.38.0+). Before v1.38.0 a create form took a slug it opened with for one set by hand, so a replicated slug never followed the title, and filled an empty slug from a source that already had text.
- **Live collision detection** — debounced probe against `GET /martis/api/resources/{resource}/slug-check/{field}?value=…&id=…`. Response envelope: `\{ "data": \{ "available": false, "reserved": false, "suggestion": "existing-post-2" }}` The UI renders a clickable suggestion when `suggestion` is non-null. The check uses the Slug declared on the form it comes from: `fieldsForUpdate()` when `id` names a record the user may update (`authorizedToUpdate()`), otherwise `fieldsForCreate()` and then `fieldsForInlineCreate()`, then `fields()`. A Slug declared on one form only is found, and that declaration's `separator()` and `reserved()` apply (v1.38.0: the check read `fields()` first and never the inline-create form). The record being edited is left out of the uniqueness probe, so its own slug reads as available; an `id` the user may not update is answered like one that names no record (v1.38.0: any record `id` named was bound and left out of the probe, which told the slug of a record the user could not edit apart). The check answers whether a slug is taken, so it needs the ability to write one, not `viewAny` alone: `authorizedToUpdate()` on the record `id` names, otherwise `authorizedToCreate()` (a user who may only list the resource gets 403, for any `id`, so it cannot be used to test which slugs exist; before v2.4.0 `viewAny` was enough). The probe reads the whole table by default, which is right for a slug that is unique across it (a plain unique index): the answer then reflects records in every tenant or owner scope, including ones the user cannot list, exactly as saving the slug would, so a user who may create or update records can learn that a slug exists elsewhere. Where the host scopes records per tenant and the slug is unique per tenant, declare the field `->withinIndexScope()`: the probe (and its `-2`, `-3` suggestions) then runs through the resource's `scopes()` and `indexQuery()` and only the records the user can list count as taken.

**Validation:** a closure rule verifies the submitted value is already in its slugified form (so the server rejects mismatched case / spaces) and that it is not in the `reserved` list.

### Timezone

**Type identifier:** `timezone` **Extends:** `Field` **File:** `src/Fields/Timezone.php`

Dropdown of every IANA timezone PHP knows about, grouped by continent.

```
Timezone::make('timezone')->default('Europe/Lisbon')
```

**Specific methods:**

- `Timezone::groupedList(): array` — Static. Returns `['Europe' => ['Europe/Lisbon', …], 'America' => [...], …]`.

**⭐ Martis extensions (UI, automatic):**

- **Live current time** — every option in the dropdown shows the zone's current local time and UTC offset. Ticks once a minute while the dropdown is open.
- **Auto-detect button** — "Use my timezone" reads `Intl.DateTimeFormat().resolvedOptions().timeZone` and fills the field.
- **Grouped + filterable** — `Europe`, `America`, `Asia`, … optgroups. The filter matches label, value, or offset (`+01:00`).

### Icon

**Type identifier:** `icon` **Extends:** `Field` **File:** `src/Fields/Icon.php`

⭐ **Martis differential** — the Icon field offers three modes, one visual output (Phosphor icon).

```
// Mode A — display-only (no DB column)
Icon::make('marker', 'rocket')->color('success')
 
// Mode B — stored in a DB column, with ⭐ visual picker
Icon::make('industry_icon')
    ->stored()
    ->palette(['rocket', 'buildings', 'briefcase', 'globe'])
    ->colorFrom('brand_color')
    ->size(20)
 
// Mode C — computed from the model
Icon::make('state')->icon(fn ($model) => $model->is_active ? 'check' : 'x')
```

**Factory:**

- `Icon::make(string $attribute, ?string $fixedIcon = null, ?string $label = null)` — when `$fixedIcon` is set, the field is display-only (Mode A) until `->stored()` flips it.

**Specific methods:**

- `stored(bool $value = true): static` — flip to Mode B. The icon name lives in the DB column named `$attribute`. Forms show a ⭐ visual picker.
- `color(string $color): static` — sets the icon tint. Accepts:  semantic tokens: `success`, `warning`, `danger`, `info`, `muted`, `accent` CSS variables: `var(--my-color)` arbitrary CSS: hex (`#ec4899`), rgb, named (`red`)
- `colorFrom(string $attribute): static` — read the color per-record from a sibling column on the same model (e.g. `brand_color`). Overrides `->color()` when that column has a value; falls back to `->color()` when empty.
- `map(array $map): static` — declarative value→icon(+color) mapping for stored fields. Accepts shortcut `['value' => 'iconName']` or full `['value' => ['icon' => '…', 'color' => '…']]`.
- `palette(array $palette): static` — whitelist for the picker. `fill()` silently drops values outside the palette — the DB never stores an icon the UI refuses to render.
- `size(int $size): static` — render size in pixels (clamped 8–64; default 16).
- `icon(Closure $resolver): static` — Mode C. Callback receives the model and returns `string` or `['icon' => '…', 'color' => '…']`.

**Behavioural notes:**

- Mode A defaults to `showOnForms = false`. `->stored()` re-enables form exposure.
- `fill()` is a no-op for Mode A / Mode C — only Mode B hydrates the model. Mode B respects `readonly()` (a closure is evaluated per request, so `readonly(fn ($request) => ! $request->user()->isAdmin())` locks the write too, not only the input) and `fillUsing()`, as every field does.
- Index rendering respects `size()` — put a small Icon at the start of `fieldsForIndex()` to get a visual marker on each row.

### Stack + Line

**Type identifier:** `stack` / `line` **Extends:** `Field` **File:** `src/Fields/Stack.php`, `src/Fields/Line.php`

Composite display field — renders a vertical stack of styled text lines as a single cell/detail slot. Ideal for compressing identity columns (name + email + company) into a single column without writing a custom component.

```
Stack::make('identity', __('fields.identity'), [
    Line::make('name')->asHeading()->subtitleFrom('email'),
    Line::make('company')->asMuted(),
])->divider();
```

**Calling styles:**

```
Stack::make('identity', [Line::make('name'), Line::make('email')]);            // attribute + lines
Stack::make('identity', 'Identity', [Line::make('name')]);                    // attribute + label + lines
```

**Line API — style variants:**

| Method | Class | When to use |
| --- | --- | --- |
| `asHeading()` | `.martis-line-heading` | primary label (bold, slightly larger) |
| `asBase()` | `.martis-line-base` | default body copy |
| `asSmall()` | `.martis-line-small` | compact secondary text |
| `asMuted()` | `.martis-line-muted` | de-emphasised supporting text |
| `asCode()` ⭐ | `.martis-line-code` | monospace pill for slugs, IDs, tokens |
| `subtitleFrom($attrOrClosure)` ⭐ | — | emit a muted second line pulled from another attribute, without declaring a second Line |

**Stack API:**

| Method | Description |
| --- | --- |
| `Stack::make($attribute, $lines)` | Create a Stack from an array of `Line` instances |
| `Stack::make($attribute, $label, $lines)` | Same, with explicit label |
| `divider(bool $enabled = true)` ⭐ | Insert a thin separator between Lines |
| `getLines(): array<Line>` | Inspect configured lines |
| `hasDivider(): bool` | Introspection helper |

**⭐ Martis differentials:**

1. **Works on the index**. Martis renders `Stack` as an index-table cell, perfect for identity columns.
2. **`->asCode()` variant** — monospace styling for slugs, hashes, tokens.
3. **`Line::subtitleFrom('attribute' | Closure)`** — one-line sugar to emit a muted secondary row without declaring a second `Line`. Accepts a Closure receiving the model for derived subtitles.
4. **`Stack::divider()`** — thin `--martis-border` separator between Lines; great for metadata listings.

**Payload shape (per row)**

The Stack emits `{ __martisStack: true, entries: [{ text, variant, subtitle }], divider }`. The frontend `StackField` component detects the wrapper and renders each entry with the appropriate `.martis-line-*` class. Custom themes restyle every Line in the package by overriding the CSS variables used inside `.martis-line-heading/base/small/muted/code`.

### Url

**Type identifier:** `url` **Extends:** `Field` **File:** `src/Fields/Url.php`

URL field with clickable link on index/detail and `url` validation. A stored value is a link only when the browser itself reads it as a web URL, a path, `mailto:` or `tel:` (v2.4.0: the same check the sidebar uses, so `java<TAB>script:` and other padded schemes are not links); anything else is shown as plain text.

```
Url::make('website')
    ->displayText('Visit Website')
    ->nullable()
```

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `displayText` | `displayText(string $text): static` | `$this` | Static display text for the link. For dynamic text, use `displayUsing()`. |
| `getDisplayText` | `getDisplayText(): ?string` | `?string` | Get display text. |

**Overrides:** `buildRules()` appends `'url'` to validation rules. **Extra attributes:** `displayText`

### Color

**Type identifier:** `color` **Extends:** `Field` **File:** `src/Fields/Color.php`

HTML5 color picker. Stores hex string (e.g. `#ff5733`).

```
Color::make('brand_color', 'Brand Color')
```

**Specific methods:** None (all from base). **Extra attributes:** None.

### Country

**Type identifier:** `country` **Extends:** `Field` **File:** `src/Fields/Country.php`

ISO 3166-1 alpha-2 country picker. Stores 2-letter code (e.g. `US`, `BR`).

```
Country::make('country')
    ->withFlags()     // show emoji flags
    ->searchable()
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `withFlags` | `withFlags(): static` | `$this` | Enable flag emoji display. Martis extension. | `false` |
| `withoutFlags` | `withoutFlags(): static` | `$this` | Disable flag emoji display. | — |
| `hasFlags` | `hasFlags(): bool` | `bool` | Check if flags enabled. | — |
| `countryList` | `static countryList(): array` | `array` | Full ISO 3166-1 country list `[{label, value, flag}]`. | — |
| `resolveCountryName` | `static resolveCountryName(string $code): ?string` | `?string` | Get country name from 2-letter code. | — |
| `resolveCountryFlag` | `static resolveCountryFlag(string $code): ?string` | `?string` | Get flag emoji from 2-letter code. | — |

**Extra attributes:** `countries`, `showFlags`

### Currency

**Type identifier:** `currency` **Extends:** `Number` **File:** `src/Fields/Currency.php`

Monetary value input with currency formatting. Inherits `min()`, `max()`, `step()`, `integer()` from Number.

```
use Martis\Enums\CurrencyCode;
use Martis\Enums\CurrencyDisplayMode;
 
Currency::make('price')
    ->currency(CurrencyCode::EUR)
    ->asMinorUnits()                                  // stored as cents
    ->displayMode(CurrencyDisplayMode::BadgeText)
    ->badgeColor('green')
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `currency` | `currency(CurrencyCode $code): static` | `$this` | Set ISO 4217 currency code (typed enum). Auto-sets step. | `CurrencyCode::USD` |
| `locale` | `locale(string $locale): static` | `$this` | Fixes the display and input locale of this field (e.g. `de_DE`), whatever the user's Martis locale. | the user's Martis locale |
| `asMinorUnits` | `asMinorUnits(): static` | `$this` | Treat stored value as minor units (cents). | `false` |
| `asMajorUnits` | `asMajorUnits(): static` | `$this` | Treat stored value as major units (dollars). | — |
| `displayMode` | `displayMode(CurrencyDisplayMode $mode): static` | `$this` | Set display mode (typed enum: `Text`, `Badge`, `BadgeText`). Martis extension. | `CurrencyDisplayMode::Text` |
| `showBadge` | `showBadge(): static` | `$this` | Shortcut for `displayMode(CurrencyDisplayMode::Badge)`. | — |
| `showText` | `showText(): static` | `$this` | Shortcut for `displayMode(CurrencyDisplayMode::Text)`. | — |
| `showBadgeText` | `showBadgeText(): static` | `$this` | Shortcut for `displayMode(CurrencyDisplayMode::BadgeText)`. | — |
| `badgeColor` | `badgeColor(string $color): static` | `$this` | Set badge color. Martis extension. | `null` |
| `getCurrencyCode` | `getCurrencyCode(): CurrencyCode` | `CurrencyCode` | Get the currency enum case. | — |
| `getLocale` | `getLocale(): string` | `string` | Get effective locale. | — |
| `isMinorUnits` | `isMinorUnits(): bool` | `bool` | Check if using minor units. | — |
| `getDisplayMode` | `getDisplayMode(): CurrencyDisplayMode` | `CurrencyDisplayMode` | Get display mode enum. | — |
| `getBadgeColor` | `getBadgeColor(): ?string` | `?string` | Get badge color. | — |
| `getCurrencyInfo` | `getCurrencyInfo(): array` | `array` | Get `{symbol, name, decimals}` for current currency. | — |

**Supported currencies:** USD, EUR, GBP, BRL, JPY, CNY, CAD, AUD, CHF, INR, MXN, KRW, SEK, NOK, DKK, PLN, THB, ZAR, TRY, RUB, NZD, SGD, HKD, CLP, ARS, COP, PEN **Extra attributes:** `currencyCode`, `currencySymbol`, `currencyName`, `currencyDecimals`, `locale`, `minorUnits`, `displayMode`, `badgeColor` + Number extras (`min`, `max`, `step`)

**Display and input:** the amount uses the separators of the user's Martis locale, unless `locale()` sets one, on the index, the detail page and the form input (v2.1.0). The `locale` extra attribute is present only when `locale()` was called.

### BelongsTo

**Type identifier:** `belongs_to` **Extends:** `Field` **File:** `src/Fields/BelongsTo.php`

Many-to-one relationship field. Stores foreign key, displays related model title.

**Which title (v2.17.0).** Without `titleAttribute()`, the field shows the related resource's `title()`, as Nova does and as its own picker already did: a `ContactResource` whose `titleAttribute()` is `label` (or that overrides `title()`) labels the cell, the detail page and the edit form the way it labels the picker row. Before v2.17.0 the field read `name` and ignored the related resource, so a model without a `name` column showed `#4`. A related resource left on the base `id` default keeps `name`, as before, and an explicit `titleAttribute()` / `displayColumn()` always wins. `Tag`, `BelongsToMany` and `MorphToMany` follow the same rule.

```
// Recommended: pass FK column (auto-detects relationship via _id suffix)
BelongsTo::make('user_id', 'Author')
    ->relatedResource('users')
    ->titleAttribute('name')
    ->sortable()
 
// Alternative: pass relationship name
BelongsTo::make('user', 'Author')
    ->relatedResource('users')
```

For many-to-many relationships use [`BelongsToMany`](https://getmartis.com/docs/core/fields#belongstomany), [`MorphToMany`](https://getmartis.com/docs/core/fields#morphtomany), or [`Tag`](https://getmartis.com/docs/core/fields#tag) — `BelongsTo` itself is single-cardinality. Since v1.38.0 a `multiple` key on its definition (`withMeta(['multiple' => true])`) changes nothing; before, it switched the input to a multi-select left over from the `multiple()` method removed in April 2026, which sent a list of ids that `fill()` stored as an empty foreign key.

```
// Inline create — show "+" button to create related record in a modal
use Martis\Enums\ModalSize;
 
BelongsTo::make('category_id', 'Category')
    ->relatedResource('categories')
    ->showCreateRelationButton()
    ->modalSize(ModalSize::Large)
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `titleAttribute` | `titleAttribute(string $attribute): static` | `$this` | Attribute on related model for display label. An explicit one always wins. | the related resource's `title()` when it declares one (a `titleAttribute()` other than `id`, or its own `title()`), else `'name'` (v2.17.0) |
| `displayColumn` | `displayColumn(string $column): static` | `$this` | Alias for `titleAttribute()`. Sets which column appears in index/table cells. | as `titleAttribute` |
| `foreignKey` | `foreignKey(string $key): static` | `$this` | Override FK column name. | `{relationship}_id` |
| `relatedResource` | `relatedResource(string $uriKey): static` | `$this` | URI key of related resource for dropdown API, and the resource a write is checked against. A key no registered resource has throws (naming the field and the key) when a value is written. Without it the write is checked against the resource registered for the relationship's model, and refused (422) when that names no single resource. See [Relationships → Writes follow the pickers](https://getmartis.com/docs/core/relationships#writes-follow-the-pickers). | `null` |
| `placeholder` | `placeholder(string\|\Closure $text): static` | `$this` | Custom placeholder shown when no value is selected. Closure receives `(?Request $r)` for per-request resolution. | translated `'Select {field}...'` |
| `relationSearchable` | `relationSearchable(bool $value = true): static` | `$this` | Enable/disable text search in dropdown. Without it the dropdown has no search box and lists up to 100 records, or the related resource's `$relatableSearchResults` (v1.38.0+; before, the flag was ignored and the search box always showed). | `true` |
| `relatableQueryUsing` | `relatableQueryUsing(\Closure $closure): static` | `$this` | Per-field constraint on the picker query. Closure receives `(Request $request, Builder $query)`: narrow the builder in place or return one (a returned `Builder` replaces the query, any other return value is ignored). Runs after the resource's static `relatableQuery()`. | `null` |
| `displayAsLink` | `displayAsLink(bool $value = true): static` | `$this` | Render as clickable link on index/detail. | `true` |
| `showCreateRelationButton` | `showCreateRelationButton(bool\|\Closure $callback = true): static` | `$this` | Show "+" button to create related record inline via modal. | `false` |
| `hideCreateRelationButton` | `hideCreateRelationButton(): static` | `$this` | Explicitly hide the inline create button. | — |
| `modalSize` | `modalSize(ModalSize $size): static` | `$this` | Set the inline create modal size. Pass any `Martis\Enums\ModalSize` case (`Small`, `Medium`, `Large`, `ExtraLarge`, `TwoExtraLarge` through `SevenExtraLarge`). | `ModalSize::TwoExtraLarge` |
| `iconColor` | `iconColor(string $color): static` | `$this` | Color for the resource icon in the inline create modal header. Any CSS color. | accent color |
| `inverse` | `inverse(string $relationship): static` | `$this` | The related resource's `HasOne` / `MorphOne` relationship that is the inverse of this one (Nova's `inverse()`): a write fails with 422 when that relationship of the picked record already holds another record. Without it, the first `HasOne` / `MorphOne` of the related resource pointing back at this resource is used. See [Relationships → Writes follow the pickers](https://getmartis.com/docs/core/relationships#writes-follow-the-pickers). | `null` |

#### Where the picker loads its options

The `BelongsTo`, `MorphTo` and `Tag` pickers list their options from the relatable endpoint of what declares the field, so that declaration (related resource, `relatableQueryUsing()`, `withoutTrashed()`) and the relatable hooks apply:

| The picker renders in | It asks |
| --- | --- |
| a resource form | `GET /api/resources/{resource}/{id}/relatable/{attribute}` (`{id}` is the record under edit, `_` on a create form) |
| an Action modal | `GET /api/resources/{resource}/actions/{action}/relatable/{attribute}` |
| a pivot action modal | `GET /api/resources/{resource}/{id}/{belongs-to-many\|morph-to-many}/{relationship}/actions/{action}/relatable/{attribute}` |
| the attach modal of a `BelongsToMany` / `MorphToMany` (a pivot field, v1.38.0+) | `GET /api/resources/{resource}/{id}/{belongs-to-many\|morph-to-many}/{relationship}/pivot-fields/relatable/{attribute}` |
| the pivot edit modal of an attached record (v1.38.0+) | `GET /api/resources/{resource}/{id}/{belongs-to-many\|morph-to-many}/{relationship}/pivot-fields/{relatedId}/relatable/{attribute}` |
| a `Repeater` row (v1.38.0+) | any of the above, with `?repeater={attribute}&repeatable={type}` naming the row |

Each is gated like the surface it serves (the form, running the Action, the attach or the pivot update), then on `viewAny` of the related resource. See [Relationships → Relation fields declared on one form only](https://getmartis.com/docs/core/relationships#relation-fields-declared-on-one-form-only), [Relation pickers among the pivot fields](https://getmartis.com/docs/core/relationships#relation-pickers-among-the-pivot-fields) and [Repeater → Relation pickers and remote selects in rows](https://getmartis.com/docs/core/repeater#relation-pickers-and-remote-selects-in-rows).

> Before v1.38.0 a picker among a relationship's pivot fields or in a Repeater row asked the page's resource for the attribute, which its forms do not declare: 404 and an empty picker.

#### Peek / Preview

The peek card appears when the user hovers the small preview icon next to a related record link. Content is fetched lazily from the related resource's `fieldsForPreview()`.

The icon is the **only** trigger; hovering the record link itself does **not** open the peek card.

The card is at most 20rem wide. A long title or value wraps onto more lines inside it; the card takes no pointer events, so nothing in it is cut off with an ellipsis (v2.16.2; before, a value longer than 12rem was truncated and could not be read).

```
// Peek is enabled by default
BelongsTo::make('author_id', 'Author')
    ->relatedResource('users')
 
// Disable peek entirely
BelongsTo::make('author_id')->noPeeking()
 
// Equivalent
BelongsTo::make('author_id')->peekable(false)
```

The peek card content is governed by the **related resource's `fieldsForPreview()` method**, which defaults to the detail fields (`fieldsForDetail()`). Override it on the resource class to control exactly which fields appear in the peek card:

```
// UserResource.php (or any resource)
public function fieldsForPreview(Request $request): array
{
    return [
        Text::make('Name'),
        Text::make('Email'),
        Text::make('Role'),
    ];
}
```

Fields can also be shown/hidden from preview individually. By default a field visible on detail is also visible in preview (`showOnPreview` falls back to `showOnDetail`).

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `peekable` | `peekable(bool $value = true): static` | `$this` | Enable/disable the peek preview icon. | `true` |
| `noPeeking` | `noPeeking(): static` | `$this` | Disable peek. Shorthand for `peekable(false)`. | — |

**Overrides:**

- `resolve()` returns `{id, title}` for the related record.
- `fill()` sets the foreign-key column on the parent model.

**Extra attributes:** `relationship`, `foreignKey`, `titleAttribute`, `relatedResource`, `relatedLabel`, `relationSearchable`, `displayAsLink`, `showCreateRelationButton`, `modalSize`, `peekable`, `placeholder`, `iconColor`

#### Toolbar controls (inherited)

All nine `hideXxx()` setters from `ControlsRelationshipToolbar` are inherited — see [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting). On `BelongsTo` only the *Create* button (inline create) and the *View* action surface are affected (per the trait scope matrix). Remember: visible = authorized AND NOT hidden. Authorization is always the source of truth; the hide flags can only hide, never force-visible. *src/Fields/Concerns/ControlsRelationshipToolbar.php*

### Tag

**Type identifier:** `tag` **Extends:** `Field` **File:** `src/Fields/Tag.php`

Relational tagging via BelongsToMany. Renders as tag chips with autocomplete.

```
Tag::make('tags', 'Tags')
    ->relatedResource('tags')
    ->titleAttribute('name')
    ->withPreview()
    ->displayAsList()
    ->showCreateRelationButton()
    ->modalSize('3xl')
    ->preload()
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `relatedResource` | `relatedResource(string $uriKey): static` | `$this` | URI key of related resource for autocomplete API. | `null` |
| `titleAttribute` | `titleAttribute(string $attribute): static` | `$this` | Display attribute on related model. An explicit one always wins. | the related resource's `title()` when it declares one (a `titleAttribute()` other than `id`, or its own `title()`), else `'name'` (v2.17.0) |
| `withPreview` | `withPreview(): static` | `$this` | Enable preview popover on hover. | `false` |
| `displayAsList` | `displayAsList(): static` | `$this` | Render as vertical list instead of chips. | `false` |
| `showCreateRelationButton` | `showCreateRelationButton(): static` | `$this` | Show inline create button. | `false` |
| `modalSize` | `modalSize(string $size): static` | `$this` | Inline creation modal size (`sm` to `7xl`). | `'2xl'` |
| `preload` | `preload(): static` | `$this` | Preload all tags on init (for small sets). | `false` |
| `relationSearchable` | `relationSearchable(bool $value = true): static` | `$this` | Enable/disable text search in the picker. Without it the picker has no search box and lists up to 100 tags, or the related resource's `$relatableSearchResults` (v1.38.0+; before, the flag was ignored). | `true` |
| `getRelationship` | `getRelationship(): string` | `string` | Get relationship method name. | — |
| `getTitleAttribute` | `getTitleAttribute(): string` | `string` | Get title attribute. | — |
| `getRelatedResource` | `getRelatedResource(): ?string` | `?string` | Get related resource URI key. | — |
| `hasPreview` | `hasPreview(): bool` | `bool` | Check if preview enabled. | — |
| `isDisplayAsList` | `isDisplayAsList(): bool` | `bool` | Check if list mode. | — |
| `isShowCreateRelationButton` | `isShowCreateRelationButton(): bool` | `bool` | Check if create button shown. | — |
| `getModalSize` | `getModalSize(): string` | `string` | Get modal size. | — |
| `isPreload` | `isPreload(): bool` | `bool` | Check if preloading. | — |

**Preview** (v1.38.0+): with `withPreview()` a tag on the index and detail pages opens the peek card of its record after a short hover: the related resource's `fieldsForPreview()`, the card a `BelongsTo` shows (see [Peek / Preview](https://getmartis.com/docs/core/fields#peek--preview)). Before v1.38.0 the method only serialised the flag and nothing opened.

**Inline create** (v1.38.0+): with `showCreateRelationButton()` the picker's dropdown ends with a *Create* entry that opens the related resource's inline-create modal (its `fieldsForInlineCreate()`, sized by `modalSize()`), and the record it creates joins the selection. The entry follows the related resource's policy (the schema serialises `showCreateRelationButton: false` when the user may not create a record there), is absent on a readonly field, and never shows on a `Tag` inside another inline-create form. Before v1.38.0 the method only serialised the flag: the picker offered no way to create a tag.

**Overrides:**

- `resolve()` loads related models, returns `[{id, title}]`.
- `fill()` registers deferred pivot sync via `DeferredRelationSync`.

**Extra attributes:** `relationship`, `titleAttribute`, `relatedResource`, `withPreview`, `displayAsList`, `showCreateRelationButton`, `modalSize`, `preload`, `relationSearchable`

#### Toolbar controls (inherited)

Tag renders a chip/autocomplete UI of its own and **does not** use the shared `RelationshipTableShell`, so the `ControlsRelationshipToolbar` trait is not applied here. Use [`BelongsToMany`](https://getmartis.com/docs/core/fields#belongstomany) when you need the full DataTable toolbar with hide flags, search, per-page, and soft-delete dropdown.

### BelongsToMany

**Type identifier:** `belongs_to_many` **Extends:** `Field` **File:** `src/Fields/BelongsToMany.php`

Full many-to-many pivot relationship field. Renders as a DataTable panel on the detail page with attach/detach, pivot field editing, search, sort, and pagination. On the index page, shows a count badge.

> **Where it shows** — the detail page and the update form; hidden from index (`->showOnIndex()` displays the count badge). Never on a create form (the create page, the create drawer, the inline-create modal): a pivot row needs the key of the record, which does not exist yet, so, like Nova, no visibility call (`showOnCreating()`, `showOnForms()`, `onlyOnForms()`) brings the field there. Attach once the record exists, or use [`Tag`](https://getmartis.com/docs/core/fields#tag) to pick related records while creating. Since **v1.38.0**. Before it, `showOnCreating()` put the panel on the create forms, where it asked `/api/resources/{resource}//belongs-to-many/{relationship}` (404) on the create page, and listed, and attached to, the page's record in a create drawer or an inline-create modal opened over another record (a Replicate drawer, for instance, attached to the record it copies).

```
// Minimal usage
BelongsToMany::make('Tags')
    ->relatedResource('tags')
 
// Full API
BelongsToMany::make('Tags', 'tags', TagResource::class)
    ->searchable()
    ->collapsable()
    ->collapsedByDefault()
    ->allowDuplicateRelations()
    ->showCreateRelationButton()
    ->modalSize('3xl')
    ->withSubtitles()
    ->dontReorderAttachables()
    ->relatableQueryUsing(fn ($request, $query) => $query->where('active', true))
    ->fields(fn () => [
        Text::make('notes', 'Notes')->nullable(),
        Date::make('expires_at', 'Expires At')->nullable(),
    ])
    ->actions(fn () => [
        ExtendExpiry::make(), // runs on the selected rows, see actions.md § Pivot Actions
    ])
    ->perPage(15)
    ->canAttach(true)
    ->canDetach(true)
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `relatedResource` | `relatedResource(string $uriKey): static` | `$this` | URI key of related resource. | inferred from relationship |
| `titleAttribute` | `titleAttribute(string $attribute): static` | `$this` | Attribute on related model for display label. An explicit one always wins. | the related resource's `title()` when it declares one (a `titleAttribute()` other than `id`, or its own `title()`), else `'name'` (v2.17.0) |
| `fields` | `fields(Closure(): list<Field>): static` | `$this` | Define pivot fields (stored on the pivot table). | `null` |
| `actions` | `actions(Closure(Request): list<Action>): static` | `$this` | Pivot actions for this relationship's panel only; `handle()` receives the selected attached records, each with its `pivot` row. See [Actions → Pivot Actions](https://getmartis.com/docs/core/actions#pivot-actions). | `null` |
| `searchable` | `searchable(bool $value = true): static` | `$this` | Enable search in the attach modal. | `false` |
| `collapsable` | `collapsable(bool $value = true): static` | `$this` | Make the panel collapsable. | `false` |
| `collapsedByDefault` | `collapsedByDefault(bool $value = true): static` | `$this` | Start the panel collapsed. Implies `collapsable`. | `false` |
| `allowDuplicateRelations` | `allowDuplicateRelations(bool $value = true): static` | `$this` | Allow attaching the same record multiple times. | `false` |
| `showCreateRelationButton` | `showCreateRelationButton(bool\|Closure $callback = true): static` | `$this` | Show inline create button in attach modal. | `false` |
| `hideCreateRelationButton` | `hideCreateRelationButton(): static` | `$this` | Explicitly hide the inline create button. | — |
| `modalSize` | `modalSize(ModalSize\|string $size): static` | `$this` | Attach modal size (`sm`, `md`, `lg`, `xl`, `2xl`–`7xl`). | `'2xl'` |
| `relatableQueryUsing` | `relatableQueryUsing(Closure $fn): static` | `$this` | Filter the query for attachable records. | `null` |
| `dontReorderAttachables` | `dontReorderAttachables(bool $value = true): static` | `$this` | Disable auto-sort of attachables (keep DB order). | `false` |
| `withSubtitles` | `withSubtitles(bool $value = true): static` | `$this` | Show subtitles in the attach modal search results. | `false` |
| `perPage` | `perPage(int $perPage): static` | `$this` | Default per-page for the inline listing. | `10` |
| `canAttach` | `canAttach(bool $value = true): static` | `$this` | Allow attaching records through the panel. Off hides the Attach button, and the attachable list, the attach (single and batch) and the attach modal's pivot pickers answer 403 (v2.4.0). | `true` |
| `canDetach` | `canDetach(bool $value = true): static` | `$this` | Allow detaching records through the panel. Off hides the Detach button and the detach endpoint answers 403 (v2.4.0). | `true` |

#### API Endpoints

| Method | Path | Action |
| --- | --- | --- |
| `GET` | `/api/resources/{resource}/{id}/belongs-to-many/{relationship}` | List attached records (paginated) |
| `GET` | `/api/resources/{resource}/{id}/belongs-to-many/{relationship}/attachable` | List records available to attach |
| `POST` | `/api/resources/{resource}/{id}/belongs-to-many/{relationship}/attach` | Attach a record (with optional pivot data) |
| `DELETE` | `/api/resources/{resource}/{id}/belongs-to-many/{relationship}/{relatedId}/detach` | Detach a record |
| `PUT` | `/api/resources/{resource}/{id}/belongs-to-many/{relationship}/{relatedId}/pivot` | Update pivot fields |
| `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) |
| `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) |

A relation picker among the pivot fields asks the panel (the two `pivot-fields` routes), gated like the panel and then like the attach (`attachAny{Model}`) or the pivot update (`updatePivot{Model}`). See [Relationships → Relation pickers among the pivot fields](https://getmartis.com/docs/core/relationships#relation-pickers-among-the-pivot-fields).

The detach and the pivot update answer 404 for a `{relatedId}` that is not attached to the parent, with the body of an id that does not exist (v2.11.1). See [Relationships → Only an attached record is detached](https://getmartis.com/docs/core/relationships#only-an-attached-record-is-detached).

#### Authorization

Override `authorizedToAttach()` or `authorizedToDetach()` on your Resource to restrict operations:

```
public function authorizedToAttach(Request $request, Model $related): bool
{
    return $request->user()?->isAdmin() ?? false;
}
 
public function authorizedToDetach(Request $request, Model $related): bool
{
    return $request->user()?->isAdmin() ?? false;
}
```

Without an override they ask the parent's policy (`attach{Model}`, `detach{Model}`; permitted when the policy does not define them). `attachAny{Model}` (`authorizedToAttachAny()`) gates the attach as a whole: when it denies, the list of records to attach, the attach and the attach modal's pivot pickers answer 403 (v1.38.0+). See [Relationships → Authorization](https://getmartis.com/docs/core/relationships#authorization).

**Overrides:**

- `resolve()` returns `null` on the detail page (data is loaded via API endpoints), or the count (integer) when shown on index.
- `fill()` is a no-op — attach/detach is handled exclusively via the dedicated API endpoints.

**Extra attributes:** `relationship`, `relatedResource`, `titleAttribute`, `searchable`, `collapsable`, `collapsedByDefault`, `allowDuplicateRelations`, `showCreateRelationButton`, `modalSize`, `withSubtitles`, `dontReorderAttachables`, `pivotFields`, `belongsToManyMeta`

#### Toolbar controls (inherited)

All nine `hideXxx()` setters from `ControlsRelationshipToolbar` are inherited — see [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting). On many-to-many fields, `hideCreateButton()` hides the *Attach* button and `hideDeleteAction()` hides the *Detach* variant. Remember: visible = authorized AND NOT hidden. Authorization is always the source of truth; the hide flags can only hide, never force-visible. *src/Fields/Concerns/ControlsRelationshipToolbar.php*

### HasOne

**Type identifier:** `has_one` **Extends:** `Field` **File:** `src/Fields/HasOne.php`

One-to-one relationship. Renders a single related record 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', 'profile', ProfileResource::class)
    ->canCreate()
    ->canUpdate()
    ->canDelete(false)
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `relatedResource` | `relatedResource(string $uriKey): static` | `$this` | URI key of the related resource. | inferred from relationship |
| `canCreate` | `canCreate(bool $value = true): static` | `$this` | Allow creating the related record through the panel. Off hides the Create button and the create endpoint answers 403 (v2.4.0). | `true` |
| `canUpdate` | `canUpdate(bool $value = true): static` | `$this` | Allow editing the related record through the panel. Off hides the Edit button and the update endpoint answers 403 (v2.4.0). | `true` |
| `canDelete` | `canDelete(bool $value = true): static` | `$this` | Allow deleting the related record through the panel. Off hides the Delete button and the delete endpoint answers 403 (v2.4.0). | `true` |

Static factory `HasOne::ofMany($name, $relationship, $resourceClass)` promotes a `hasMany()->latestOfMany()` relation into a [`HasOneOfMany`](https://getmartis.com/docs/core/fields#hasoneofmany) field. *src/Fields/HasOne.php*

#### Toolbar controls (inherited)

All nine `hideXxx()` setters from `ControlsRelationshipToolbar` are inherited — see [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting). `HasOne` renders no toolbar, so only the action hide flags (`hideViewAction` / `hideEditAction` / `hideDeleteAction`) have a visible effect per the trait scope matrix. Remember: visible = authorized AND NOT hidden. Authorization is always the source of truth; the hide flags can only hide, never force-visible.

See [relationships.md — HasOne](https://getmartis.com/docs/core/relationships#hasone) for the full guide.

### HasOneOfMany

**Type identifier:** `has_one_of_many` **Extends:** `HasOne` **File:** `src/Fields/HasOneOfMany.php`

Promotes a `hasMany()->latestOfMany()` (or `->ofMany(col, aggregate)`) relationship so the admin displays the latest / oldest record as if it were a plain `HasOne`. Ships an optional metric tile via `aggregateVia()` and a "latest of N" pill next to the panel heading.

```
use Martis\Enums\AggregateFunction;
use Martis\Fields\HasOneOfMany;
 
HasOneOfMany::make('Latest Invoice', 'latestInvoice', InvoiceResource::class)
    ->latestByTimestamp('paid_at')
    ->aggregateVia(AggregateFunction::Sum, 'amount');
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| *All `HasOne` setters* | — | `$this` | Inherited. | — |
| `latestByTimestamp` | `latestByTimestamp(string $column = 'created_at'): static` | `$this` | Orders the underlying relation by the timestamp column descending before picking the first row; a tie goes to the highest primary key. The related model's `created_at` / `updated_at` are qualified with its table; another column is used as given, so over a through relation qualify a column both tables have (`'projects.published_at'`). Show, Edit, Delete and the card's writes all target that record. | `'created_at'` |
| `oldestByTimestamp` | `oldestByTimestamp(string $column = 'created_at'): static` | `$this` | Ascending counterpart of `latestByTimestamp()`; a tie goes to the lowest primary key. | `'created_at'` |
| `aggregateVia` | `aggregateVia(AggregateFunction $function, string $column = '*'): static` | `$this` | Emits a metric tile computed across this parent's full collection (count/sum/min/max/avg), through the relation's own keys (a through relation included). On an Eloquent one-of-many relation (`latestOfMany()`, `ofMany()`), the tile and the "latest of N" pill cover every related row of the parent: the constraints written into that relation do not apply; the global scopes it removes stay removed. The tile formats the value in the user's Martis locale, with up to two decimals and no currency symbol (v2.1.0; it showed EUR for columns named like money). | disabled |

*src/Fields/HasOneOfMany.php*

#### Toolbar controls (inherited)

All nine `hideXxx()` setters from `ControlsRelationshipToolbar` are inherited (via `HasOne`) — see [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting). Only the action hide flags (`hideViewAction` / `hideEditAction` / `hideDeleteAction`) have a visible effect on this single-record panel. Remember: visible = authorized AND NOT hidden. Authorization is always the source of truth; the hide flags can only hide, never force-visible.

See [relationships.md — HasOneOfMany](https://getmartis.com/docs/core/relationships#hasoneofmany) for the full guide.

### HasOneThrough

**Type identifier:** `has_one_through` **Extends:** `HasOne` **File:** `src/Fields/HasOneThrough.php`

Shows a single distant record reached through an intermediate model (`hasOneThrough`). As in Nova, no Create: `canCreate()` has no effect and the `has-one` endpoints refuse a create through a `hasOneThrough` relationship with a 403, whichever field declares it. Edit and Delete work as on `HasOne` (see [relationships.md § HasOneThrough](https://getmartis.com/docs/core/relationships#hasonethrough)).

```
use Martis\Fields\HasOneThrough;
 
HasOneThrough::make('Account Manager', 'accountManager', TeamMemberResource::class)
    ->throughBreadcrumb();
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| *All `HasOne` setters* | — | `$this` | Inherited, except `canCreate`, which has no effect (always `false`; `canCreate(true)` logs a warning naming the field). | — |
| `throughBreadcrumb` | `throughBreadcrumb(bool $enabled = true, ?string $text = null): static` | `$this` | Adds a "through" hint next to the section heading. Pass a custom `$text` to override the default label. | `false` |

*src/Fields/HasOneThrough.php*

#### Toolbar controls (inherited)

All nine `hideXxx()` setters from `ControlsRelationshipToolbar` are inherited — see [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting). Because a Through field never offers Create, `hideCreateButton()` is redundant; the other `hideXxx()` setters work as on `HasOne`. Remember: visible = authorized AND NOT hidden. Authorization is always the source of truth; the hide flags can only hide, never force-visible.

See [relationships.md — HasOneThrough](https://getmartis.com/docs/core/relationships#hasonethrough) for the full guide.

### HasMany

**Type identifier:** `has_many` **Extends:** `Field` **File:** `src/Fields/HasMany.php`

One-to-many relationship. Renders an inline DataTable panel on the detail page with full inline CRUD (create, edit, delete), search, sort, per-page, and pagination via `RelationshipTableShell`. Detail-only by default — use `->showOnIndex()` to display a count badge on index. The count, like the panel, includes only the records the related resource's index lists (its `scopes()` and `indexQuery()`, v2.0), and the index computes it for the whole page in its own query. That query resolves the relationship as Laravel's `withCount()` and eager loading do, on a model that holds no record: a relationship method that reads the parent's attributes gets `null` there. One that throws without its record is counted on each row instead; one that reads `null` counts the wrong rows, so a relationship counted on the index should not depend on the parent's attributes.

```
use Martis\Fields\HasMany;
use Martis\Enums\HasManyIndexDisplay;
use Martis\Enums\HasManyRedirectMode;
 
HasMany::make('Comments', 'comments')
    ->relatedResource('comments')
    ->collapsable()
    ->collapsedByDefault()
    ->perPage(15)
    ->showRelationCount()
    ->redirectAfterSave(HasManyRedirectMode::Parent);
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `relatedResource` | `relatedResource(string $uriKey): static` | `$this` | URI key of the related resource. | inferred from relationship |
| `perPage` | `perPage(int $perPage): static` | `$this` | Default per-page for the inline listing. | `10` |
| `perPageOptions` | `perPageOptions(array $options): static` | `$this` | Custom per-page selector options. | resolved from related resource / `[5,10,25,50]` |
| `canCreate` | `canCreate(bool $value = true): static` | `$this` | Allow creating a related record through the panel. Off hides the Create button and the create endpoint answers 403 (v2.4.0). | `true` |
| `canUpdate` | `canUpdate(bool $value = true): static` | `$this` | Allow editing a related record through the panel. Off hides the Edit action and the update endpoint answers 403 (v2.4.0). | `true` |
| `canDelete` | `canDelete(bool $value = true): static` | `$this` | Allow deleting a related record through the panel. Off hides the Delete action and the delete endpoint answers 403 (v2.4.0). | `true` |
| `relationSearchable` | `relationSearchable(bool $value = true): static` | `$this` | Enable the search input in the panel toolbar. | `false` |
| `indexDisplay` | `indexDisplay(HasManyIndexDisplay $mode): static` | `$this` | Configure how the field renders when shown on the index page. | — |
| `showRelationIcon` | `showRelationIcon(bool $value = true): static` | `$this` | Show the related-resource icon in the panel heading. | `true` |
| `showRelationCount` | `showRelationCount(bool $value = true): static` | `$this` | Show the total count badge in the panel heading. | `true` |
| `badgeColor` | `badgeColor(string $color): static` | `$this` | Override the count badge colour for index display. | — |
| `badgeIcon` | `badgeIcon(string $icon): static` | `$this` | Override the panel icon. | — |
| `redirectAfterSave` | `redirectAfterSave(HasManyRedirectMode $mode): static` | `$this` | Where to redirect after saving a related record. | — |
| `collapsable` | `collapsable(bool $value = true): static` | `$this` | Make the panel collapsable. | `false` |
| `collapsedByDefault` | `collapsedByDefault(bool $value = true): static` | `$this` | Start collapsed. Implies `collapsable`. | `false` |

*src/Fields/HasMany.php*

#### Toolbar controls (inherited)

All nine `hideXxx()` setters from `ControlsRelationshipToolbar` are inherited — see [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting). On `HasMany` the full surface is affected (search / create / per-page / soft-delete dropdown + view / edit / delete / restore / force-delete). Remember: visible = authorized AND NOT hidden. Authorization is always the source of truth; the hide flags can only hide, never force-visible.

See [relationships.md — HasMany](https://getmartis.com/docs/core/relationships#hasmany) for the full guide and [Relationship panel anatomy](https://getmartis.com/docs/core/relationships#relationship-panel-anatomy) for the shared shell layout.

### HasManyThrough

**Type identifier:** `has_many_through` **Extends:** `HasMany` **File:** `src/Fields/HasManyThrough.php`

Inline DataTable of many records reached through an intermediate (`hasManyThrough`). As in Nova, no Create: `canCreate()` has no effect and the `has-many` endpoints refuse a create through a `hasManyThrough` relationship with a 403, whichever field declares it. The rows keep View / Edit / Delete / Restore / Force delete as on `HasMany` (see [relationships.md § HasManyThrough](https://getmartis.com/docs/core/relationships#hasmanythrough)).

```
use Martis\Fields\HasManyThrough;
 
HasManyThrough::make('Managed Projects', 'managedProjects', ProjectResource::class)
    ->throughBreadcrumb()
    ->countBadge();
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| *All `HasMany` setters* | — | `$this` | Inherited, except `canCreate`, which has no effect (always `false`; `canCreate(true)` logs a warning naming the field). | — |
| `throughBreadcrumb` | `throughBreadcrumb(bool $enabled = true, ?string $text = null): static` | `$this` | Adds a "through" hint next to the section heading. | `false` |
| `countBadge` | `countBadge(bool $enabled = true): static` | `$this` | Renders a count pill on the parent resource's index cell. | `true` |

*src/Fields/HasManyThrough.php*

#### Toolbar controls (inherited)

All nine `hideXxx()` setters from `ControlsRelationshipToolbar` are inherited (via `HasMany`) — see [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting). Since the field never offers Create, `hideCreateButton()` is redundant; the other `hideXxx()` setters work as on `HasMany` (search / per-page / soft-delete dropdown + view / edit / delete / restore / force-delete). Remember: visible = authorized AND NOT hidden. Authorization is always the source of truth; the hide flags can only hide, never force-visible.

See [relationships.md — HasManyThrough](https://getmartis.com/docs/core/relationships#hasmanythrough) for the full guide.

### MorphOne

**Type identifier:** `morph_one` **Extends:** `Field` **File:** `src/Fields/MorphOne.php`

Polymorphic one-to-one relationship (`morphOne`). Same UI as `HasOne` but for polymorphic relations. Detail-only by default.

```
use Martis\Fields\MorphOne;
 
MorphOne::make('Thumbnail', 'thumbnail', ThumbnailResource::class)
    ->canCreate()
    ->canUpdate();
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `relatedResource` | `relatedResource(string $uriKey): static` | `$this` | URI key of the related resource. | inferred from relationship |
| `canCreate` | `canCreate(bool $value = true): static` | `$this` | Allow creating a related record through the panel. Off hides the Create button and the create endpoint answers 403 (v2.4.0). | `true` |
| `canUpdate` | `canUpdate(bool $value = true): static` | `$this` | Allow editing the related record through the panel. Off hides the Edit button and the update endpoint answers 403 (v2.4.0). | `true` |
| `canDelete` | `canDelete(bool $value = true): static` | `$this` | Allow deleting the related record through the panel. Off hides the Delete button and the delete endpoint answers 403 (v2.4.0). | `true` |

*src/Fields/MorphOne.php*

#### Toolbar controls (inherited)

All nine `hideXxx()` setters from `ControlsRelationshipToolbar` are inherited — see [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting). Same scope as `HasOne`: only the action hide flags have a visible effect. Remember: visible = authorized AND NOT hidden. Authorization is always the source of truth; the hide flags can only hide, never force-visible.

See [relationships.md — MorphOne](https://getmartis.com/docs/core/relationships#morphone) for the full guide.

### MorphOneOfMany

**Type identifier:** `morph_one_of_many` **Extends:** `MorphOne` **File:** `src/Fields/MorphOneOfMany.php`

Polymorphic counterpart of `HasOneOfMany`. Promotes a `morphMany()->latestOfMany()` (or `->ofMany(col, aggregate)`) relationship.

```
use Martis\Enums\AggregateFunction;
use Martis\Fields\MorphOneOfMany;
 
MorphOneOfMany::make('Latest Note', 'latestNote', NoteResource::class)
    ->latestByTimestamp()
    ->aggregateVia(AggregateFunction::Count, '*');
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| *All `MorphOne` setters* | — | `$this` | Inherited. | — |
| `latestByTimestamp` | `latestByTimestamp(string $column = 'created_at'): static` | `$this` | Orders by the timestamp descending before picking the first row; a tie goes to the highest primary key. | `'created_at'` |
| `oldestByTimestamp` | `oldestByTimestamp(string $column = 'created_at'): static` | `$this` | Ascending counterpart; a tie goes to the lowest primary key. | `'created_at'` |
| `aggregateVia` | `aggregateVia(AggregateFunction $function, string $column = '*'): static` | `$this` | Emits a metric tile computed across this parent's full collection. On an Eloquent one-of-many relation (`latestOfMany()`, `ofMany()`), the tile and the "latest of N" pill cover every related row of the parent: the constraints written into that relation do not apply; the global scopes it removes stay removed. | disabled |

*src/Fields/MorphOneOfMany.php*

#### Toolbar controls (inherited)

All nine `hideXxx()` setters from `ControlsRelationshipToolbar` are inherited (via `MorphOne`) — see [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting). Only the action hide flags have a visible effect on this single-record panel. Remember: visible = authorized AND NOT hidden. Authorization is always the source of truth; the hide flags can only hide, never force-visible.

See [relationships.md — MorphOneOfMany](https://getmartis.com/docs/core/relationships#morphoneofmany) for the full guide.

### MorphMany

**Type identifier:** `morph_many` **Extends:** `Field` **File:** `src/Fields/MorphMany.php`

Polymorphic one-to-many relationship (`morphMany`). Same DataTable UI as `HasMany` — full inline CRUD, search, sort, per-page, pagination — via `RelationshipTableShell`. Detail-only by default.

```
use Martis\Fields\MorphMany;
 
MorphMany::make('Comments', 'comments', CommentResource::class)
    ->collapsable()
    ->collapsedByDefault()
    ->perPage(10)
    ->canCreate(false);
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `relatedResource` | `relatedResource(string $uriKey): static` | `$this` | URI key of the related resource. | inferred from relationship |
| `perPage` | `perPage(int $perPage): static` | `$this` | Default per-page for the inline listing. | `10` |
| `perPageOptions` | `perPageOptions(array $options): static` | `$this` | Custom per-page selector options. | resolved from related resource / `[5,10,25,50]` |
| `canCreate` | `canCreate(bool $value = true): static` | `$this` | Allow creating a related record through the panel. Off hides the Create button and the create endpoint answers 403 (v2.4.0). | `true` |
| `canUpdate` | `canUpdate(bool $value = true): static` | `$this` | Allow editing a related record through the panel. Off hides the Edit action and the update endpoint answers 403 (v2.4.0). | `true` |
| `canDelete` | `canDelete(bool $value = true): static` | `$this` | Allow deleting a related record through the panel. Off hides the Delete action and the delete endpoint answers 403 (v2.4.0). | `true` |
| `relationSearchable` | `relationSearchable(bool $value = true): static` | `$this` | Enable the search input in the panel toolbar. | `false` |
| `indexDisplay` | `indexDisplay(HasManyIndexDisplay $mode): static` | `$this` | Configure how the field renders when shown on the index page. | — |
| `showRelationIcon` | `showRelationIcon(bool $value = true): static` | `$this` | Show the related-resource icon in the panel heading. | `true` |
| `showRelationCount` | `showRelationCount(bool $value = true): static` | `$this` | Show the total count badge in the panel heading. | `true` |
| `badgeColor` | `badgeColor(string $color): static` | `$this` | Override the count badge colour. | — |
| `badgeIcon` | `badgeIcon(string $icon): static` | `$this` | Override the panel icon. | — |
| `redirectAfterSave` | `redirectAfterSave(HasManyRedirectMode $mode): static` | `$this` | Where to redirect after saving a related record. | — |

*src/Fields/MorphMany.php*

> `MorphMany` does not expose `collapsable()` / `collapsedByDefault()` setters of its own — see src for details.

#### Toolbar controls (inherited)

All nine `hideXxx()` setters from `ControlsRelationshipToolbar` are inherited — see [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting). Full scope applies: search / create / per-page / soft-delete dropdown and all five row actions. Remember: visible = authorized AND NOT hidden. Authorization is always the source of truth; the hide flags can only hide, never force-visible.

See [relationships.md — MorphMany](https://getmartis.com/docs/core/relationships#morphmany) for the full guide and [Relationship panel anatomy](https://getmartis.com/docs/core/relationships#relationship-panel-anatomy) for the shared shell layout.

### MorphTo

**Type identifier:** `morph_to` **Extends:** `Field` **File:** `src/Fields/MorphTo.php`

Polymorphic many-to-one relationship — a record can belong to one of several different model types via a single relationship. The frontend renders a two-step picker: first a type dropdown (the resource families allowed by `types()`), then a record search filtered to the chosen type.

```
use Martis\Enums\ModalSize;
use Martis\Fields\MorphTo;
 
MorphTo::make('commentable', 'Commentable')
    ->types([PostResource::class, UserResource::class])
    ->titleAttribute('name')
    ->showCreateRelationButton()
    ->modalSize(ModalSize::Large)
    ->nullable();
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `types` | `types(array $resourceClasses): static` | `$this` | Allowed resource families for this polymorphic relationship. Each entry is a `Resource` class name; the model class is derived via `newModel()`. | — |
| `titleAttribute` | `titleAttribute(string $attr): static` | `$this` | Attribute used for the display label in the picker and on detail rows. | `'name'` |
| `relationSearchable` | `relationSearchable(bool $value = true): static` | `$this` | Enable / disable text search inside the record dropdown. Without it the dropdown has no search box and lists up to 100 records of the picked type, or its resource's `$relatableSearchResults` (v1.38.0+; before, the flag was ignored). | `true` |
| `showCreateRelationButton` | `showCreateRelationButton(bool\|\Closure $callback = true): static` | `$this` | Show the inline "+" button that creates a related record per type. | `false` |
| `hideCreateRelationButton` | `hideCreateRelationButton(): static` | `$this` | Explicitly hide the inline create button. | — |
| `modalSize` | `modalSize(ModalSize $size): static` | `$this` | Modal size for the inline create flow. Pass any `Martis\Enums\ModalSize` case (`Small` through `SevenExtraLarge`). | `ModalSize::TwoExtraLarge` |
| `nullable` | `nullable(): static` | `$this` | Make the relationship optional on forms. | `false` |

**Resolve format**

```
{
  "type": "App\\Models\\Post",
  "id": 42,
  "title": "Post Title",
  "resourceType": "posts"
}
```

**Fill format**

The frontend submits:

```
{ "resourceType": "posts", "id": 42 }
```

The backend resolves the model class from the resource URI key and writes both `commentable_type` and `commentable_id` columns on the parent. The resolve format (`type` + `id`) is accepted too, which is what an edit form sends for a target the user did not change; `type` must name one of the field's `types()` (the model class or its morph-map alias), exactly as `resourceType` must name one of their resources, otherwise it is ignored (v1.38.0+; before, any class name was written). Any value that is not a map is ignored, so the stored target stays as it was.

Since v1.38.0 the update page and the update drawer send the MorphTo map as is. They used to reduce every `{ id, title }` value to its id (right for a `BelongsTo`), which caught the MorphTo map too, so a target changed on an edit form was never saved.

**Inline create**

Inline create is per-type — the create button appears only after the operator picks a type. Nesting is limited to one level (no inline create inside an inline create). A readonly MorphTo (a `readonly()` field, an `immutable()` one on an update form) offers no create button (v1.38.0+; before, the button stayed live and created a record the save then dropped). The button shows only for a type the user may create: each `morphTypes` entry of the schema carries that type's `authorizedToCreate`, and `showCreateRelationButton` holds as soon as one type is creatable (v1.38.0+; before, every type showed the button, and the modal of a type the user cannot create failed with a 403 on its schema). The related resource's `fieldsForInlineCreate()` controls which fields show; falls back to `fieldsForCreate()`.

**Toolbar controls (inherited)**

All nine `hideXxx()` setters from `ControlsRelationshipToolbar` are inherited — see [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting). On `MorphTo` only the *Create* button (inline create) and the *View* action surface are affected (per the trait scope matrix). Remember: visible = authorized AND NOT hidden. Authorization is always the source of truth; the hide flags can only hide, never force-visible. *src/Fields/Concerns/ControlsRelationshipToolbar.php*

### MorphToMany

**Type identifier:** `morph_to_many` **Extends:** `Field` **File:** `src/Fields/MorphToMany.php`

Polymorphic many-to-many relationship (`morphToMany`). Same pivot UI as `BelongsToMany` — DataTable, attach/detach, pivot fields, pivot actions, search, sort, per-page, pagination — via `RelationshipTableShell`. Shown where `BelongsToMany` shows: the detail page and the update form, never a create form (see the note under [BelongsToMany](https://getmartis.com/docs/core/fields#belongstomany)).

```
use Martis\Fields\MorphToMany;
 
MorphToMany::make('Tags', 'tags', TagResource::class)
    ->titleAttribute('name')
    ->searchable()
    ->collapsable()
    ->fields(fn () => [
        Text::make('notes', 'Notes')->nullable(),
    ]);
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `relatedResource` | `relatedResource(string $uriKey): static` | `$this` | URI key of the related resource. | inferred from relationship |
| `titleAttribute` | `titleAttribute(string $attribute): static` | `$this` | Display attribute on the related model. An explicit one always wins. | the related resource's `title()` when it declares one (a `titleAttribute()` other than `id`, or its own `title()`), else `'name'` (v2.17.0) |
| `fields` | `fields(Closure $closure): static` | `$this` | Define pivot fields (stored on the morph-pivot table). | — |
| `actions` | `actions(Closure(Request): list<Action>): static` | `$this` | Pivot actions for this relationship's panel only; `handle()` receives the selected attached records, each with its `pivot` row. See [Actions → Pivot Actions](https://getmartis.com/docs/core/actions#pivot-actions). | — |
| `searchable` | `searchable(bool $value = true): static` | `$this` | Enable search in the attach modal. | `false` |
| `collapsable` | `collapsable(bool $value = true): static` | `$this` | Make the panel collapsable. | `false` |
| `collapsedByDefault` | `collapsedByDefault(bool $value = true): static` | `$this` | Start collapsed. Implies `collapsable`. | `false` |
| `allowDuplicateRelations` | `allowDuplicateRelations(bool $value = true): static` | `$this` | Allow attaching the same record multiple times. | `false` |
| `showCreateRelationButton` | `showCreateRelationButton(bool\|Closure $callback = true): static` | `$this` | Show the inline create button in the attach modal. | `false` |
| `hideCreateRelationButton` | `hideCreateRelationButton(): static` | `$this` | Explicitly hide the inline create button. | — |
| `modalSize` | `modalSize(ModalSize $size, ?string $height = null): static` | `$this` | Attach modal size / optional height. | `'2xl'` |
| `relatableQueryUsing` | `relatableQueryUsing(Closure $closure): static` | `$this` | Filter the attachable record list. | — |
| `dontReorderAttachables` | `dontReorderAttachables(bool $value = true): static` | `$this` | Keep DB order in the attachable list. | `false` |
| `withSubtitles` | `withSubtitles(bool $value = true): static` | `$this` | Show subtitles in search results. | `false` |
| `subtitleAttribute` | `subtitleAttribute(string $attribute): static` | `$this` | Column used as the subtitle. | — |
| `perPage` | `perPage(int $perPage): static` | `$this` | Default per-page for the inline listing. | `10` |
| `perPageOptions` | `perPageOptions(array $options): static` | `$this` | Custom per-page selector options. | resolved from related resource / `[5,10,25,50]` |
| `canAttach` | `canAttach(bool $value = true): static` | `$this` | Allow attaching records through the panel. Off hides the Attach button, and the attachable list, the attach (single and batch) and the attach modal's pivot pickers answer 403 (v2.4.0). | `true` |
| `canDetach` | `canDetach(bool $value = true): static` | `$this` | Allow detaching records through the panel. Off hides the Detach button and the detach endpoint answers 403 (v2.4.0). | `true` |

A relation picker among the pivot fields asks the panel, under `/api/resources/{resource}/{id}/morph-to-many/{relationship}/pivot-fields/relatable/{attribute}` in the attach modal and `.../pivot-fields/{relatedId}/relatable/{attribute}` in the pivot edit modal (v1.38.0+), gated like a `BelongsToMany`'s (see [Relationships → Relation pickers among the pivot fields](https://getmartis.com/docs/core/relationships#relation-pickers-among-the-pivot-fields)).

*src/Fields/MorphToMany.php*

#### Toolbar controls (inherited)

All nine `hideXxx()` setters from `ControlsRelationshipToolbar` are inherited — see [relationships.md § Toolbar hide flags](https://getmartis.com/docs/core/relationships#toolbar-hide-flags-cross-cutting). Same scope as `BelongsToMany`: `hideCreateButton()` hides the *Attach* button and `hideDeleteAction()` hides the *Detach* variant. Remember: visible = authorized AND NOT hidden. Authorization is always the source of truth; the hide flags can only hide, never force-visible.

See [relationships.md — MorphToMany](https://getmartis.com/docs/core/relationships#morphtomany) for the full guide and [Relationship panel anatomy](https://getmartis.com/docs/core/relationships#relationship-panel-anatomy) for the shared shell layout.

### File

**Type identifier:** `file` **Extends:** `Field` **File:** `src/Fields/File.php`

File upload with configurable disk and storage path.

```
File::make('attachment', 'Attachment')
    ->disk('public')
    ->storagePath('uploads/docs')
    ->maxSize(10240)                  // 10MB in KB
    ->acceptedTypes(['pdf', 'doc'])
    ->multiple()
    ->preserveOriginalName()
    ->sanitizeFileName()
    ->nullable()
    ->hideFileInfo()
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `disk` | `disk(string $disk): static` | `$this` | Set Laravel filesystem disk. | `'public'` |
| `getDisk` | `getDisk(): string` | `string` | Get disk name. | — |
| `storagePath` | `storagePath(string $path): static` | `$this` | Set subdirectory within disk. | `'uploads'` |
| `maxSize` | `maxSize(int $kb): static` | `$this` | Set max file size in KB. | `null` |
| `acceptedTypes` | `acceptedTypes(array $mimes): static` | `$this` | Restrict accepted file extensions. A security control, see [Active content](https://getmartis.com/docs/core/fields#file-active-content): listing an active type (`'svg'`, `'html'`) accepts it. | `[]` |
| `allowActiveContent` | `allowActiveContent(bool $value = true): static` | `$this` | Accept HTML, SVG, XML and script files, which are refused by default. v2.4.0+. | `false` |
| `allowsActiveContent` | `allowsActiveContent(): bool` | `bool` | Whether the field accepts active content. | — |
| `multiple` | `multiple(bool $value = true): static` | `$this` | Enable multiple file uploads (stores JSON array). | `false` |
| `isMultiple` | `isMultiple(): bool` | `bool` | Check if multiple mode. | — |
| `preserveOriginalName` | `preserveOriginalName(bool $value = true): static` | `$this` | Keep original filename (with unique suffix). | `false` |
| `sanitizeFileName` | `sanitizeFileName(bool\|callable $sanitizer = true): static` | `$this` | Sanitize filenames. Accepts `true` for default or a custom callable. | `false` |
| `showFileInfo` | `showFileInfo(bool $value = true): static` | `$this` | Show/hide file info (max size, types). | `true` |
| `hideFileInfo` | `hideFileInfo(): static` | `$this` | Hide file info display. | — |
| `deleteStoredFile` | `deleteStoredFile(Model $model): void` | `void` | Delete stored file(s) from disk. | — |
| `buildItemRules` | `buildItemRules(): array` | `array` | Validation rules for each item in multiple mode. | — |

**Overrides:**

- `resolve()` returns `{path, url, name}` (single) or `[{path, url, name}]` (multiple).
- `fill()` stores uploaded file, deletes old, supports multiple mode. In multiple mode the path list is JSON-encoded unless the attribute carries an `array` / `json` / class cast that serialises it itself (see [Structured values and Eloquent casts](https://getmartis.com/docs/core/fields#structured-values-and-eloquent-casts)), and only `existing` paths the record already owns are kept: the list is client-supplied, so an injected path (another record's upload, a traversal) is dropped and an owned path the client omits is deleted from disk.
- `buildRules()` adds `file`, `mimes:...`, `max:...` rules, and the active-content rule below (`Martis\Rules\NoActiveContent`) unless `allowActiveContent()` is set. `buildItemRules()` adds the same to each upload of a multiple field.

**Extra attributes:** `disk`, `storagePath`, `maxSize`, `acceptedTypes`, `multiple`, `showFileInfo`

#### Active content is refused (v2.4.0+)

The default disk is `public`, which the web server serves from the application's own origin (`APP_URL/storage`). An HTML or SVG document served from there runs its script in that origin, with the session of whoever opens the link: a panel user who may upload to a `File` field could plant a page that an administrator later opens, and the script can read the CSRF token and drive the panel API as that administrator. So a `File` (and an `Image`, an `Avatar`, an `Audio`, which extend it) answers `422` to an upload that a browser or the web server would run, unless the developer opts in:

- **by extension**: the extension the file is stored with, every segment of it (`html`, `htm`, `xhtml`, `shtml`, `svg`, `svgz`, `xml`, `xsl`, the other XML documents a browser renders (`rss`, `atom`, `rdf`, `owl`, `xsd`, `dtd`, `xbl`, `xul`, `mathml`, `wsdl`, `xspf`, `xaml`, `smil`), `swf`, `js`, `mjs`, `php`, `php3` to `php8`, `phtml`, `pht`, `phar`, `asp`, `aspx`, `jsp`, `cgi`, `htaccess` and the like, see `Martis\Rules\NoActiveContent::EXTENSIONS`). That is the one the field gives it: the hash name takes it from the content's MIME type, and `preserveOriginalName()` keeps the client's own (so a GIF with a script in it kept as `logo.html` is refused);
- **by content**: the MIME type the server reads from the file's bytes, never the one the client claims (`text/html`, `image/svg+xml`, `application/xml` and every other `+xml` type, JavaScript and PHP types), so an HTML document named `report.pdf` is refused as well.

The opt-in is explicit, per field:

```
File::make('attachment')->acceptedTypes(['pdf', 'docx']);   // active content refused (it is not listed)
File::make('logo')->acceptedTypes(['svg', 'png']);          // listing 'svg' accepts SVG, and nothing else active
File::make('snippet')->allowActiveContent();                // accepts every active type
```

**`acceptedTypes()` is a security control, not a convenience.** Without it a field takes any file that is not active content; list the types the field is for. Accept active content only when the uploads are never served from the application's origin: a private disk behind a download route that sends `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`, a separate domain, or a web-server rule for the upload directory.

The refusal also holds at `fill()` for a caller that skips validation: the field throws a `ValidationException` before it deletes the file it replaces or stores a new one, in single and multiple mode alike. The message is `martis::validation.active_content` (translated in `en`, `pt_PT`, `pt_BR`).

An `Image` never accepted SVG; what changes for it is the kept name: `Image::make('x')->preserveOriginalName()` now refuses an image uploaded under an active extension (a polyglot).

### Image

**Type identifier:** `image` **Extends:** `File` **File:** `src/Fields/Image.php`

Image upload with thumbnail generation. Inherits all File methods.

```
Image::make('featured_image', 'Featured Image')
    ->disk('public')
    ->storagePath('posts/images')
    ->thumbnail(400, 300)
    ->maxSize(5120)
    ->acceptedTypes(['jpg', 'jpeg', 'png', 'webp'])
    ->multiple()
    ->preserveOriginalName()
    ->sanitizeFileName()
    ->nullable()
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `thumbnail` | `thumbnail(int $width = 300, int $height = 300): static` | `$this` | Enable thumbnail generation (GD or Intervention Image). | disabled |

**Default accepted types:** jpg, jpeg, png, gif, webp, bmp (SVG excluded: XSS risk). **Overrides:**

- `resolve()` returns `{path, url, name, thumbnailUrl}`.
- `fill()` generates thumbnail after storing image. In multiple mode the path list follows the same cast-aware storage and the same owned-paths guard as `File` (see [Structured values and Eloquent casts](https://getmartis.com/docs/core/fields#structured-values-and-eloquent-casts)): only `existing` paths the record already owns are kept, and an owned path the client omits is deleted together with its thumbnail. Before v1.37.3 `Image` kept any client-supplied path.
- `buildRules()` uses `image` instead of `file`.
- `deleteStoredFile()` also deletes thumbnail.

**Extra attributes:** File extras + `thumbnailWidth`, `thumbnailHeight`

### Code

**Type identifier:** `code` **Extends:** `Field` **File:** `src/Fields/Code.php`

Syntax-highlighted code editor (CodeMirror 6). Hidden from index by default.

```
use Martis\Enums\CodeLanguage;
 
Code::make('config', 'Configuration')
    ->language(CodeLanguage::Yaml)
    ->json()
    ->rules(['json'])
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `json` | `json(): static` | `$this` | Treat as JSON: pretty-print on resolve, decode on fill. | `false` |
| `language` | `language(CodeLanguage $language): static` | `$this` | Set syntax-highlighting language (typed enum). | `CodeLanguage::Javascript` |
| `isJson` | `isJson(): bool` | `bool` | Check if JSON mode. | — |
| `getLanguage` | `getLanguage(): CodeLanguage` | `CodeLanguage` | Get the configured language enum case. | — |

**Supported languages:** dockerfile, htmlmixed, javascript, markdown, nginx, php, ruby, sass, shell, sql, twig, vim, vue, xml, yaml-frontmatter, yaml **Overrides:** `resolve()` pretty-prints JSON; `fill()` decodes JSON before storing. **Extra attributes:** `json`, `language`

### Markdown

**Type identifier:** `markdown` **Extends:** `Field` **File:** `src/Fields/Markdown.php`

Markdown editor with preview. Stores raw Markdown. Hidden from index by default.

The panel renders the Markdown in the browser (`marked`) and sanitises the HTML before it reaches the page, so raw HTML a user wrote in the Markdown cannot run script for whoever opens the record. Scripts, event handlers and `javascript:` URLs are removed, and so are the things that would restyle or impersonate the panel around the content: every `data-*` attribute (the global tooltip reads `data-pr-*` ones), the `style` attribute and element, `id` and `name`, forms and form controls other than the disabled checkbox of a task list (`label`, `fieldset`, `datalist`, `output` and every other `input` go), and the `class` attribute except `language-<name>`, the class a fenced code block carries (the stylesheet defines layout utilities such as `fixed` and `z-50`, which content must not be able to use to paint a fake panel over the page). The `zero` preset escapes HTML instead. The stored value is the Markdown as written: sanitise it yourself if you render it outside the panel.

```
Markdown::make('content')
    ->alwaysShow()
    ->preset('default')
    ->withFiles('public')
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `alwaysShow` | `alwaysShow(): static` | `$this` | Always expand content on detail (skip "Show Content" toggle). | `false` |
| `preset` | `preset(string $preset): static` | `$this` | Markdown rendering preset: `'default'` (GFM), `'commonmark'`, `'zero'`. | `'default'` |
| `withFiles` | `withFiles(?string $disk = null): static` | `$this` | Enable file uploads in the editor, stored on `$disk` (the panel's `martis.storage.disk` without one). The upload endpoint takes the disk from the field and authorises the upload like the form the field is on (v2.4.0); see [Resources → Attachment Uploads](https://getmartis.com/docs/core/resources#attachment-uploads-trix--markdown). | disabled |
| `isAlwaysShow` | `isAlwaysShow(): bool` | `bool` | Check if always showing. | — |
| `getPreset` | `getPreset(): string` | `string` | Get preset. | — |
| `getWithFilesDisk` | `getWithFilesDisk(): ?string` | `?string` | The uploads disk, or `null` when the field does not accept files. | — |

**Extra attributes:** `alwaysShow`, `preset`, `withFiles`

### Trix

**Type identifier:** `trix` **Extends:** `Field` **File:** `src/Fields/Trix.php`

Rich text HTML editor (Trix). Stores raw HTML. Hidden from index by default.

The stored value is the HTML the editor produced, and the JSON API accepts any string for the attribute, so the panel sanitises it before it renders the detail view: scripts, event handlers and `javascript:` URLs are removed, and so are every `data-*` attribute except the three a Trix attachment uses (`data-trix-attachment`, `data-trix-content-type`, `data-trix-attributes`), the `style` attribute and element, `id` and `name`, forms and form controls (`label`, `fieldset`, `datalist`, `output` and every `input` included), and every `class` except the ones Trix writes for an attachment (`attachment`, `attachment--*`, `attachment__*`, `attachment-gallery`, `attachment-gallery--*`), so content cannot borrow a layout utility class of the panel to paint over it. An attachment whose JSON names a `url` / `href` that is not an `http(s)` URL or a path of the app loses its `data-trix-attachment`, and clicking an attachment or a link only follows an `http(s)` URL. **The package does not purify the HTML on the server**: the value is stored as received, so sanitise it (for example with `mews/purifier` or `symfony/html-sanitizer`) wherever you render it outside the panel.

```
Trix::make('body', 'Body')
    ->alwaysShow()
    ->withFiles('public')
    ->toolbarSize('sm')
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `alwaysShow` | `alwaysShow(): static` | `$this` | Always expand content on detail. | `false` |
| `withFiles` | `withFiles(?string $disk = null): static` | `$this` | Enable file uploads in the editor, stored on `$disk` (the panel's `martis.storage.disk` without one). The upload endpoint takes the disk from the field and authorises the upload like the form the field is on (v2.4.0); see [Resources → Attachment Uploads](https://getmartis.com/docs/core/resources#attachment-uploads-trix--markdown). | disabled |
| `toolbarSize` | `toolbarSize(string $size): static` | `$this` | Toolbar button size: `'sm'`, `'md'`, `'lg'`. | `null` |
| `isAlwaysShow` | `isAlwaysShow(): bool` | `bool` | Check if always showing. | — |
| `getWithFilesDisk` | `getWithFilesDisk(): ?string` | `?string` | The uploads disk, or `null` when the field does not accept files. | — |

**Extra attributes:** `alwaysShow`, `withFiles`, `toolbarSize`

### KeyValue

**Type identifier:** `key_value` **Extends:** `Field` **File:** `src/Fields/KeyValue.php`

Dynamic key-value pair editor. Stores as JSON. Hidden from index by default.

```
KeyValue::make('metadata', 'Metadata')
    ->keyLabel('Property')
    ->valueLabel('Setting')
    ->actionText('Add Property')
    ->disableEditingKeys()
    ->disableAddingRows()
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `keyLabel` | `keyLabel(string $label): static` | `$this` | Label for key column header. | `'Key'` |
| `valueLabel` | `valueLabel(string $label): static` | `$this` | Label for value column header. | `'Value'` |
| `actionText` | `actionText(string $text): static` | `$this` | Label for "add row" button. | `'Add Row'` |
| `disableEditingKeys` | `disableEditingKeys(): static` | `$this` | Prevent editing existing keys; a write drops any key outside the stored (or default) key set (v2.4.0+). | `false` |
| `disableAddingRows` | `disableAddingRows(): static` | `$this` | Prevent adding new rows; a write drops a new row but keeps a renamed key (v2.4.0+). | `false` |
| `disableDeletingRows` | `disableDeletingRows(): static` | `$this` | Prevent deleting rows: no row renders a delete button, and a write that leaves a stored (or default) key out gets its value back (v2.4.0+). v1.38.0+. | `false` |
| `getKeyLabel` | `getKeyLabel(): string` | `string` | Get key label. | — |
| `getValueLabel` | `getValueLabel(): string` | `string` | Get value label. | — |
| `getActionText` | `getActionText(): string` | `string` | Get action text. | — |
| `isEditingKeysDisabled` | `isEditingKeysDisabled(): bool` | `bool` | Check if key editing disabled. | — |
| `isAddingRowsDisabled` | `isAddingRowsDisabled(): bool` | `bool` | Check if adding disabled. | — |
| `isDeletingRowsDisabled` | `isDeletingRowsDisabled(): bool` | `bool` | Check if deleting disabled. v1.38.0+. | — |

**A fixed set of keys.** The three `disable*` flags are independent. To present a map whose keys are fixed and only the values are editable (opening hours per weekday, one row per locale), declare all three; with only `disableEditingKeys()->disableAddingRows()` the operator can still delete a row, and saving then drops that key from the stored map:

```
KeyValue::make('opening_hours', 'Opening hours')
    ->keyLabel('Day')
    ->valueLabel('Hours')
    ->disableEditingKeys()
    ->disableAddingRows()
    ->disableDeletingRows()
```

**The server enforces the flags** (v2.4.0+, hardening: Nova leaves them to the form). A request does not have to go through the form, so `fill()` holds a payload to the key set the flags fix. That set is the stored map's keys for a record that has a map, and the field's `default()` keys for a new record and for a record whose stored map is empty (a closure or rows are read as the form reads them):

| Flag | What a write does |
| --- | --- |
| `disableEditingKeys()` | A submitted key outside the set is dropped: a new row, and a key edited into another name. The values of the keys in the set are the user's. |
| `disableAddingRows()` | A new row is dropped, but the form still lets a user rename a key and delete a row, so a key edited into another name is kept as a rename: a new key may take the place of a key of the set the submission leaves out, and the old key does not come back beside it. Only as many new keys as keys left out are kept (the surplus is a real new row). |
| `disableDeletingRows()` | A key of the set the submission leaves out (a deleted row, or the old name of a key edited into another one) takes its stored (or default) value back, and an empty value restores the whole set. A rename under `disableAddingRows()` replaces the old key instead of restoring it. |

Nothing changes for a field without these flags: any key is stored. A key edited into another name is a new key plus a missing one: with `disableEditingKeys()` the renamed key is dropped and the old one is deleted (the form offers no way to type a key then), with `disableDeletingRows()` too the old key stays, and with `disableAddingRows()` alone the rename is kept. A record that has no stored map (created before the field existed, or emptied) is held to the `default()` keys, so its edits are saved instead of silently dropped. The stored order of the set is kept, and a restored row keeps its stored value as it was (a nested value included).

**Storage format:** `{"key1":"value1","key2":"value2"}` **Overrides:** `resolve()` decodes to `[{key, value}]` rows; `fill()` normalizes to the associative map and stores it, JSON-encoded unless the attribute carries an `array` / `json` / class cast that serialises it itself (see [Structured values and Eloquent casts](https://getmartis.com/docs/core/fields#structured-values-and-eloquent-casts)). **Extra attributes:** `keyLabel`, `valueLabel`, `actionText`, `editingKeysDisabled`, `addingRowsDisabled`, `deletingRowsDisabled`

### Heading

**Type identifier:** `heading` **Extends:** `Field` **File:** `src/Fields/Heading.php`

Visual section divider. Not a data field — does not read/write model attributes. Hidden from index by default.

```
Heading::make('media_section', 'Media')
    ->content('Featured image and attachments')
```

| Method | Signature | Returns | Description |
| --- | --- | --- | --- |
| `content` | `content(string $text): static` | `$this` | Descriptive text below the heading. |

**Overrides:** `resolve()` returns `null`; `fill()` is a no-op. **Extra attributes:** `content`

### Hidden

**Type identifier:** `hidden` **Extends:** `Field` **File:** `src/Fields/Hidden.php`

Hidden form input. Invisible in UI — never shown on index or detail.

```
Hidden::make('return_to')
```

**Default overrides:**

- `showOnIndex = false`
- `showOnDetail = false`

**Specific methods:** None.

> **A Hidden value is client-controlled.** A hidden input is not a trust boundary: `fill()` writes whatever the request posts for the attribute, so anyone who may create or update the record can edit the request and send another value. `Hidden::make('tenant_id')->default(fn ($request) => $request->user()->tenant_id)` pre-fills the form, it does not protect the column: a user with create rights posts another tenant's id and the record lands there. Use `Hidden` for values the user may legitimately choose (a UI hint, a return URL), never for a tenant, an owner or any other column that scopes access. Set those on the server: `// 1. A model event: the column never comes from the request.protected static function booted(): void\{ static::creating(function (self $model): void \{ $model->tenant_id ??= auth()->user()?->tenant_id; });} // 2. The resource's beforeSave(): runs after the fields are filled, before the save.public function beforeSave(Model $model, Request $request, bool $creating): void\{ if ($creating) \{ $model->tenant_id = $request->user()->tenant_id; }  parent::beforeSave($model, $request, $creating);} // 3. A field that ignores the posted value (fillUsing()), or never takes one (readonly()):Hidden::make('tenant_id')->fillUsing(fn (Model $model) => $model->tenant_id = auth()->user()->tenant_id);Text::make('tenant_id')->readonly(); // fill() writes nothing; the application sets the column` The same holds for any field a user can edit: validate or overwrite on the server what the request must not decide. A model event is the broadest of the three (it also covers the inline creates of a relationship panel and anything else that saves the model); see [Lifecycle hooks](https://getmartis.com/docs/core/resources#lifecycle-hooks) and [Authorization](https://getmartis.com/docs/core/resources#authorization).

### Badge

**Type identifier:** `badge` **Extends:** `Field` **File:** `src/Fields/Badge.php`

Visual read-only indicator. Maps model values to colored badges. Display-only (hidden from forms by default).

```
Badge::make('status')
    ->map([
        'draft'     => 'warning',
        'published' => 'success',
        'archived'  => 'danger',
    ])
    ->withIcons()
    ->icons([
        'warning' => 'pencil',
        'success' => 'check-circle',
        'danger'  => 'archive',
    ])
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `map` | `map(array\|Closure $map): static` | `$this` | Value → badge type. Closure resolves once at schema build. | `[]` |
| `labels` | `labels(array\|Closure $labels): static` | `$this` | Value → translated display label. Closure form supported. | `[]` |
| `types` | `types(array\|Closure $types): static` | `$this` | Override badge type definitions (replaces defaults). | `info, success, warning, danger` |
| `addTypes` | `addTypes(array $types): static` | `$this` | Merge extra badge types onto the current set. | — |
| `withIcons` | `withIcons(): static` | `$this` | Enable icon rendering in badges. | `false` |
| `icons` | `icons(array\|Closure $icons): static` | `$this` | Badge type → icon name (also enables icons). | `[]` |
| `resolveBadgeUsing` ⭐ | `resolveBadgeUsing(Closure $fn): static` | `$this` | Per-row override — closure receives `(value, model)` and returns `['type', 'label', 'icon']`. | — |
| `getMap` | `getMap(): array` | `array` | Get resolved value-to-type map. | — |
| `getTypes` | `getTypes(): array` | `array` | Get resolved type definitions. | — |
| `hasIcons` | `hasIcons(): bool` | `bool` | Check if icons enabled. | — |
| `getIcons` | `getIcons(): array` | `array` | Get resolved type-to-icon map. | — |

**Default badge types:** `info` (blue), `success` (green), `warning` (yellow), `danger` (red) **Extra attributes:** `map`, `labels`, `types`, `withIcons`, `icons`

#### ⭐ Dynamic maps & per-row resolution (Martis differentials)

All static setters (`map`, `labels`, `types`, `icons`) accept a Closure that's resolved **once** when the schema is serialised — ideal for enum-backed maps and config-driven palettes:

```
Badge::make('status')
    ->map(fn () => StatusEnum::badgeMap())
    ->labels(fn () => StatusEnum::labels());
```

For row-specific decisions, use `resolveBadgeUsing()`. The closure runs during per-row serialisation and its return value (any subset of `type`, `label`, `icon`) is shipped verbatim to the frontend. Missing keys fall back to the static `map`/`labels`/`icons` lookup:

```
Badge::make('status')
    ->map(['active' => 'success', 'paused' => 'warning'])
    ->labels(['active' => 'Active', 'paused' => 'Paused'])
    ->resolveBadgeUsing(function (?string $value, Model $model) {
        if ($model->is_vip && $value === 'active') {
            return ['type' => 'vip-gold', 'label' => 'VIP ⭐', 'icon' => 'crown'];
        }
        return []; // fall back to the static map/labels for everyone else
    });
```

> [!important] Badge is display-only — use `Select` in forms `Badge` is intentionally filtered out of create/update contexts (`showOnCreation = showOnUpdate = false` by default). It is meant for index/detail display only. If you want the user to **pick** a value that renders as a badge afterwards, use `Select` in `fieldsForCreate`/`fieldsForUpdate` and keep `Badge` in `fieldsForIndex`/`fieldsForDetail`. **Wrong — field disappears from the edit drawer:** `public function fields(Request $request): array\{ return [ Badge::make('status')->map([ 'active' => 'success', 'inactive' => 'warning', ]), ];}` **Right — editable input in forms, badge in index/detail:** `public function fieldsForIndex(Request $request): array\{ return [ Badge::make('status')->map([ 'active' => 'success', 'inactive' => 'warning', ]), ];} public function fieldsForCreate(Request $request): array\{ return [ Select::make('status')->options([ 'active' => 'Active', 'inactive' => 'Inactive', ])->required(), ];} public function fieldsForUpdate(Request $request): array\{ return $this->fieldsForCreate($request);}` Calling `->showOnForms()` on a Badge forces it into forms as read-only — useful to *show* the current value while another field edits it, but never as an input.

### Badges

**Type identifier:** `badges` **Extends:** `Badge` **File:** `src/Fields/Badges.php`

One value drawn as several badge pills. Display-only, like `Badge` (hidden from forms by default; `showOnForms()` renders the same read-only pills). The resolved value is a list of items, and each item is a string (label only, default type) or `['label' => string, 'type' => ?string, 'tooltip' => ?string]`.

```
Badges::make('languages', 'Languages')
    ->resolveUsing(fn ($value, $model) => [
        ['label' => 'EN', 'type' => 'success'],
        ['label' => 'Missing PT', 'type' => 'warning', 'tooltip' => 'No translation yet'],
        'FR',
    ]);
```

- `type` uses the `Badge` vocabulary: `info`, `success`, `warning`, `danger`, a key you add with `types()`/`addTypes()`, a hex colour, or a value translated by `map()`. A missing or unknown type renders the default pill.
- `tooltip` shows through the global Martis tooltip, never a native `title`.
- Labels are plain text; HTML is not rendered.
- An empty list (or `null`) renders the empty placeholder.
- An item that is not a string, or an array without a string `label`, throws an `InvalidArgumentException` naming the field attribute.
- The pills are a list (`role="list"` / `listitem`).
- `labels()`, `icons()`, `withIcons()` and `resolveBadgeUsing()` inherited from `Badge` do not apply: each item carries its own label.

**Extra attributes:** `map`, `types`

### Status

**Type identifier:** `status` **Extends:** `Field` **File:** `src/Fields/Status.php`

Visual state/progress indicator with loading and failed states. Display-only (hidden from forms by default).

```
Status::make('job_status', 'Job Status')
    ->loadingWhen(['waiting', 'running', 'queued'])
    ->failedWhen(['failed', 'errored', 'cancelled'])
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `loadingWhen` | `loadingWhen(array $values): static` | `$this` | Values that trigger spinner. | `[]` |
| `failedWhen` | `failedWhen(array $values): static` | `$this` | Values that trigger error indicator. | `[]` |
| `getLoadingWhen` | `getLoadingWhen(): array` | `array` | Get loading values. | — |
| `getFailedWhen` | `getFailedWhen(): array` | `array` | Get failed values. | — |

**Behavior:** Values not in either list render as "success" (completed). **Extra attributes:** `loadingWhen`, `failedWhen`

### Gravatar

**Type identifier:** `gravatar` **Extends:** `Field` **File:** `src/Fields/Gravatar.php`

Display-only avatar from Gravatar. Generates URL from email hash. Hidden from forms by default. `fromUrl()` switches it to a stored avatar URL, the one mode that writes.

```
Gravatar::make()               // default: attribute='email', label='Avatar'
Gravatar::make('user_email')   // custom attribute
    ->squared()
    ->size(80)
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `squared` | `squared(): static` | `$this` | Display with square edges. | — |
| `rounded` | `rounded(): static` | `$this` | Display with rounded (circle) edges. | `'rounded'` |
| `size` | `size(int $size): static` | `$this` | Avatar size in pixels. | `40` |
| `getShape` | `getShape(): string` | `string` | Get shape (`'rounded'` or `'squared'`). | — |
| `getSize` | `getSize(): int` | `int` | Get size in pixels. | — |
| `gravatarUrl` | `static gravatarUrl(string $email, int $size = 40): string` | `string` | Generate Gravatar URL from email. | — |

**Overrides:** `resolve()` returns Gravatar URL (not raw email) in email mode and the stored URL in URL mode. `fill()` is a no-op in email mode (the field never writes the generated URL over the email column). In URL mode (`fromUrl()`) it writes the submitted URL, after the seams of every field: a readonly field (a `readonly()` closure included) writes nothing and a `fillUsing()` callback takes the write over. `buildRules()` adds a rule in URL mode (a field that is not readonly or computed): the value must be an absolute `https://` URL (`martis::validation.https_url`), because it is rendered as an `<img src>` for every viewer; an empty value passes and writes nothing. A stored `http://` avatar fails the rule when the form sends it back until it is replaced (since v2.4.0). **Extra attributes:** `shape`, `avatarSize`

### Sparkline

**Type identifier:** `sparkline` **Extends:** `Field` **File:** `src/Fields/Sparkline.php`

Inline mini chart for trend visualization. Display-only (hidden from forms by default).

The chart draws at most 300 points: a longer stored series is downsampled (the mean of each slice) in the browser, and a value that is not a finite number is skipped. When you show the field on a form (`showOnForms()`), a written series is validated: an `array` of at most `maxPoints()` numbers (1000 unless you raise it), so an unbounded series is refused with a 422 instead of being stored. A readonly or computed field, and one with `fillUsing()`, keep their own rules.

```
Sparkline::make('trend', 'Revenue Trend')
    ->data([10, 20, 15, 40, 35, 50])
    ->asBarChart()
    ->height(40)
    ->chartWidth(120)
    ->color('#22c55e')
```

| Method | Signature | Returns | Description | Default |
| --- | --- | --- | --- | --- |
| `data` | `data(array\|callable $data): static` | `$this` | Set chart data (array of numbers or callable receiving model). | `null` |
| `asBarChart` | `asBarChart(): static` | `$this` | Render as bar chart. | — |
| `asLineChart` | `asLineChart(): static` | `$this` | Render as line chart. | `'line'` |
| `height` | `height(int $px): static` | `$this` | Chart height in pixels. | `30` |
| `chartWidth` | `chartWidth(int $px): static` | `$this` | SVG canvas width in pixels. Renamed from `width()` — the base `Field::width(string)` now controls the index column width. | `null` |
| `color` | `color(string $color): static` | `$this` | Chart line/bar color (CSS color). | `'#6366f1'` |
| `maxPoints` | `maxPoints(int $max): static` | `$this` | Most numbers a written series may hold (validated when the field is shown on a form). | `1000` |
| `getMaxPoints` | `getMaxPoints(): int` | `int` | Get the limit. | — |
| `getChartType` | `getChartType(): string` | `string` | Get chart type. | — |
| `getChartHeight` | `getChartHeight(): int` | `int` | Get height. | — |
| `getChartWidth` | `getChartWidth(): ?int` | `?int` | Get width. | — |
| `getChartColor` | `getChartColor(): string` | `string` | Get color. | — |

**Overrides:** `resolve()` returns data array (invokes callable if set, falls back to model attribute); `fill()` writes the submitted points (a JSON string is decoded first; the field is hidden from forms by default, so it only runs after `showOnForms()`), and honours `readonly()` (a closure included) and `fillUsing()` like every field; `buildRules()` adds `array`, `max:{maxPoints}` and a numbers-only check. **Extra attributes:** `chartType`, `chartHeight`, `chartWidth`, `chartColor`

### Repeater

**File:** `src/Fields/Repeater.php` + `src/Fields/Repeatable.php`

Repeatable row widget backed by JSON, HasMany or ⭐ polymorphic (single child table with a `type` discriminator). See the dedicated [Repeater guide](https://getmartis.com/docs/core/repeater) for the full API. Highlights:

| Method | Signature | Description |
| --- | --- | --- |
| `repeatables` | `repeatables(array<Repeatable>)` | Register the row types available in the Add menu |
| `asJson` | `asJson(): static` | Persist on a JSON-cast parent attribute |
| `asHasMany` | `asHasMany(): static` | Persist via a child table with 3-way upsert |
| `asPolymorphic` ⭐ | `asPolymorphic(string $type = 'type', string $payload = 'payload')` | One child table for every row type |
| `uniqueField` | `uniqueField(string)` | Column used to identify rows across saves |
| `confirmRemoval` | `confirmRemoval(bool = true)` | Open a confirmation modal on remove |
| `minRows` / `maxRows` ⭐ | `minRows(int)` / `maxRows(int)` | Cardinality shown in the form (Add disables at the max, a notice shows below the min); add `rules(['array', 'min:N', 'max:N'])` to enforce it on the server |
| `collapsible` ⭐ | `collapsible(bool = true)` | Add collapse chevron to every row |
| `collapsedByDefault` ⭐ | `collapsedByDefault(bool = true)` | Start collapsed |
| `reorderable` ⭐ | `reorderable(bool = true, ?string $column = null)` | Drag-and-drop reorder |
| `dependsOn` ⭐ | `dependsOn(array<string>)` | Expose parent attributes to every inner field |
| `rowTemplates` ⭐ | `rowTemplates(array)` | Pre-filled rows available in the Add menu |

**Repeatable** header decorations (⭐): `icon`, `color`, `title` (template or closure), `badgeCount`. Row-level UX extras: duplicate button per row, bulk-paste modal that parses TSV/CSV/JSON.

A `BelongsTo`, `MorphTo` or `Tag` in a row type lists its options, and a `Select` with `searchOptionsUsing()` searches them, from the row (v1.38.0+): see [Repeater → Relation pickers and remote selects in rows](https://getmartis.com/docs/core/repeater#relation-pickers-and-remote-selects-in-rows).

The fields inside the rows are validated on the server with their own rules, under `{attribute}.{row}.fields.{field}`, and each error shows under its row field (since v1.38.0): see [Repeater § Validation](https://getmartis.com/docs/core/repeater#validation).

## Utility Classes

### DeferredRelationSync

**File:** `src/Fields/DeferredRelationSync.php`

Static registry for deferred many-to-many relationship syncs. Used by `Tag`, whose pivot rows can only be written after the parent model has been saved (so the parent's primary key is available). No other bundled field registers syncs here: `BelongsTo` has no multiple mode (use `BelongsToMany` or `Tag` for a N:N relation).

| Method | Signature | Description |
| --- | --- | --- |
| `register` | `static register(Model $model, string $relationship, array $ids): void` | Register a relationship sync to be executed after save. |
| `sync` | `static sync(Model $model): void` | Execute all pending syncs for a model, then clear them. |

Uses `WeakMap` keyed by model instances for automatic garbage collection. Every controller that saves a record through its fields calls `sync()` right after the save: the resource's own create and update and, since v1.38.0, the HasMany / HasOne / MorphMany / MorphOne inline forms (up to v1.37.3 those saved the record and dropped the `Tag` values, and the rows of a HasMany or polymorphic `Repeater`, queued for it).

### FieldContext (Enum)

**File:** `src/FieldContext.php`

| Case | Value | Description |
| --- | --- | --- |
| `INDEX` | `'index'` | List/table view |
| `DETAIL` | `'detail'` | Show/detail view |
| `CREATE` | `'create'` | Create form |
| `UPDATE` | `'update'` | Edit form |
| `INLINE_CREATE` | `'inline-create'` | Inline creation modal |
| `PREVIEW` | `'preview'` | Preview mode |

## Component Resolution Order

When rendering a field, the React frontend resolves the component in this order:

1. **Explicit key** — `->component('custom-key')` set in PHP
2. **Override by resource** — `componentRegistry.registerResourceFieldDisplay(resource, field, comp)`
3. **Override global by type** — `componentRegistry.registerFieldDisplay(type, comp)`
4. **Default component** — built-in Martis component for the type

See [Override System](https://getmartis.com/docs/customization/overrides) for details.

## Resource Replication

Martis supports resource replication. When a user clicks "Replicate" on a detail page, they are redirected to the create form with pre-filled field values from the source record. The record is **not** saved until the user submits the form.

### How It Works

1. User clicks "Replicate" on `ResourceDetail`
2. Frontend navigates to `/resources/{resource}/create?fromResourceId={id}` (a resource with a create override opens that override instead, with `fromResourceId`; see [Overrides → Override Props](https://getmartis.com/docs/customization/overrides#override-props))
3. `ResourceCreate` (or the create drawer) fetches pre-fill data from `GET /api/resources/{resource}/{id}/replicate`
4. Form is pre-filled with source record values (File fields excluded)
5. User can modify values and submit to create the new record; the create carries `fromResourceId`, and the success toast reads the resource's `replicatedMessage()` (v1.38.0+; before, it always read `createdMessage()`)

The form keeps its loading skeleton until the copied values have filled it, then mounts the fields, as Nova's Replicate view does, so every input starts from the copy (v1.38.0+). A response without `values` opens an empty form, and a failed request shows the error page. A copied `Slug` stays as copied until the title changes, and then follows it (see [Slug](https://getmartis.com/docs/core/fields#slug)). Before v1.38.0 the fields mounted empty and received the copy one render later: an input that reads its value when it mounts showed nothing of it, and the copied slug was replaced at once by one made from the copied title.

### API Endpoint

```
GET /api/resources/{resource}/{id}/replicate
```

**Response:**

```
{
  "values": { "name": "Copy of ...", "description": "..." },
  "fromResourceId": 42
}
```

### Customization

Override `fieldsForCreate()` on your resource to control which fields appear in the replicate form. File fields are automatically excluded from replication.

## Inline Create

BelongsTo fields can display a "+" button that opens a modal for creating a related record inline, without leaving the current form. This is controlled by `showCreateRelationButton()` on the BelongsTo field. `MorphTo` offers the same per type, and `Tag` from its picker's dropdown (v1.38.0+); see their sections.

### How It Works

1. BelongsTo field renders with a "+" button when `showCreateRelationButton()` is enabled
2. Clicking "+" opens a modal with the related resource's inline create fields
3. The related resource defines `fieldsForInlineCreate()` for a reduced field set
4. On submit, the new record is created and automatically selected in the BelongsTo dropdown
5. Nesting is limited to 1 level (no inline create inside an inline create): the inline-create schema turns `showCreateRelationButton` off for its `BelongsTo`, `MorphTo` and `Tag` fields (`Tag` since v1.38.0)
6. A readonly field offers no "+" (v1.38.0+): a `readonly()` field, an `immutable()` one on an update form, and the `BelongsTo` a nested create locks to its parent. Before v1.38.0 the "+" stayed live there and created a record the save then dropped.

Closing the modal discards what was typed in it, so it opens on an empty form every time (v1.38.0+). Before v1.38.0 the modal emptied its form one render after it opened again, so an input that reads its value when it mounts kept the text typed the previous time.

### API Endpoints

```
GET  /api/resources/{resource}/inline-create-schema
POST /api/resources/{resource}/inline-create
```

**Schema response:**

```
{
  "fields": [
    { "attribute": "name", "label": "Name", "type": "text" }
  ]
}
```

**Store request/response:**

```
// Request
{ "name": "New Category" }
 
// Response
{ "id": 5, "title": "New Category" }
```

### Resource Configuration

```
// In your resource class
public function fieldsForInlineCreate(Request $request): array
{
    return [
        Text::make('name', 'Name')->required(),
        Textarea::make('description', 'Description'),
    ];
}
```

Falls back to `fieldsForCreate()` if not overridden.

Resources

Relationships
