Documentation menu
HomeDocsCore ConceptsFields

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

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

MethodSignatureDescription
makestatic make(string $attribute, ?string $label = null): staticCreate 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

MethodSignatureDescription
attributeattribute(): stringReturn the model attribute name (e.g. "title").
labellabel(): stringReturn the human-readable label (e.g. "Title").
typetype(): stringReturn the field type identifier (e.g. "text", "boolean"). Abstract — each subclass implements.

Value Resolution & Filling

MethodSignatureDescription
resolveresolve(Model $model, ?string $attribute = null): mixedRead 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.
resolveForDisplayresolveForDisplay(Model $model, ?string $attribute = null): mixedResolve then apply displayUsing() callback. Use for index/detail serialization.
fillfill(Model $model, mixed $value): voidWrite 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).
hasStructuredValuehasStructuredValue(): boolWhether 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.
rejectsUnstructuredValuerejectsUnstructuredValue(): boolWhether 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 valueMultipart bodyServer side
Filethe 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 objectJSON.stringify(value)decoded back to the array before validation and fill for every field whose hasStructuredValue() is true
other scalarString(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

MethodSignatureReturnsDescription
nullablenullable(bool|Closure $value = true): static$thisMark as nullable (adds nullable validation rule). Accepts a closure for request-time resolution.
sensitivesensitive(bool $value = true): static$thisMark the value as a secret: an Action's field that collects an API key or a token. The action event log stores ****** for it instead of the value. Password and PasswordConfirmation are sensitive by default. See Sensitive fields. v2.4.0+.
isSensitiveisSensitive(): boolboolWhether the value is a secret.
readonlyreadonly(bool|Closure $value = true): static$thisPrevent 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). 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). 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).
requiredrequired(bool|Closure $value = true): static$thisRequire 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.
placeholderplaceholder(string|Closure $text): static$thisSet placeholder text for the input. Accepts a closure for request-time resolution.
helphelp(string|Closure $text): static$thisSet 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.
tooltiptooltip(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.
withLabelwithLabel(string|Closure $value): static$thisOverride the constructor label after construction. Accepts a closure for request-time resolution.
fullWidthfullWidth(bool $fullWidth = true): static$thisMake 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).
hideLabelOnDetailhideLabelOnDetail(bool $hide = true): static$thisHide 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+.
stackedstacked(bool $stacked = true): static$thisControl label position on forms: stacked above (true) or inline (false). The detail rows do not read it (it defaults to true); use fullWidth() there.
defaultdefault(mixed $value): static$thisSet 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.
stickysticky(bool $value = true): static$thisKeep 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

MethodSignatureReturnsDescription
showOnIndexshowOnIndex(): static$thisShow on the index (list) view.
hideFromIndexhideFromIndex(): static$thisHide from the index view.
showOnDetailshowOnDetail(): static$thisShow on the detail (show) view.
hideFromDetailhideFromDetail(): static$thisHide from the detail view.
showOnFormsshowOnForms(): static$thisShow on create and edit forms.
hideFromFormshideFromForms(): static$thisHide from create and edit forms.
isShownOnIndexisShownOnIndex(): boolboolCheck if visible on index.
isShownOnDetailisShownOnDetail(): boolboolCheck if visible on detail.
isShownOnFormsisShownOnForms(): boolboolCheck if visible on forms.

Granular Visibility

MethodSignatureReturnsDescription
hideWhenCreatinghideWhenCreating(): static$thisHide on create form only.
hideWhenUpdatinghideWhenUpdating(): static$thisHide on update form only.
showOnCreatingshowOnCreating(): static$thisShow 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 to pick related records while creating.
showOnUpdatingshowOnUpdating(): static$thisShow on update form.

Convenience Visibility Presets

MethodSignatureReturnsDescription
onlyOnIndexonlyOnIndex(): static$thisShow only on index; hide everywhere else.
onlyOnDetailonlyOnDetail(): static$thisShow only on detail; hide everywhere else.
onlyOnFormsonlyOnForms(): static$thisShow only on create/update forms; hide everywhere else.
exceptOnFormsexceptOnForms(): static$thisShow everywhere except forms.

Context-Aware Visibility

MethodSignatureDescription
isVisibleForContextisVisibleForContext(string $context): boolCheck visibility for a given context. Contexts: index, detail, create, update, inline-create, preview.
filterForContextstatic filterForContext(array $fields, string $context): arrayFilter an array of fields to only those visible in the given context.

Resolution rules per context:

ContextResolves from
indexshowOnIndex
detailshowOnDetail
create / inline-createshowOnCreate ?? showOnForms
updateshowOnUpdate ?? showOnForms
previewshowOnPreview ?? showOnDetail

Field authorization: canSee() and canSeeForModel()

MethodSignatureDescription
canSeecanSee(callable $callback): staticShow the field only when $callback(Request $request) returns true.
canSeeWhencanSeeWhen(string $ability, mixed ...$arguments): staticcanSee() through a Gate ability of the current user.
canSeeForModelcanSeeForModel(callable $callback): staticShow the field on a record only when $callback(Request $request, Model $model) returns true for that record.
canSeeUsingPolicycanSeeUsingPolicy(string $ability): staticcanSeeForModel() through a Gate ability of the current user on the record.
isAuthorizedToSeeisAuthorizedToSee(Request $request): boolResolve canSee().
isAuthorizedForModelisAuthorizedForModel(Request $request, Model $model): boolResolve canSeeForModel() for a record.
filterForModelstatic filterForModel(array $fields, Request $request, Model $model): arrayThe fields of a list the user may see on a record (v1.38.0).
sortableAttributesstatic sortableAttributes(array $items, Request $request): arrayThe attributes a request may sort a list by: those of the sortable() fields the user may see, layout containers opened (v1.38.0).
searchableFieldsstatic searchableFields(array $items, Request $request): arrayThe fields a search matches its term on: the searchable() fields the user may see, layout containers opened (v1.38.0).
hiddenAttributesstatic hiddenAttributes(array $fields, array $visible): arrayThe attributes of the fields of a list a record hides, given the ones filterForModel() kept: a serialised record's _hidden (v1.38.0).
filterLayoutFieldsstatic filterLayoutFields(array $items, Closure $keep): arrayA 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.
  • 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).
  • 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) 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); 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

MethodSignatureReturnsDescription
sortablesortable(bool $value = true): static$thisAllow sorting the index table by this field, for the users who can see it (see Field authorization).
searchablesearchable(bool $value = true): static$thisMatch the searches (resource index, relationship panels, relation pickers, global search) on this field, for the users who can see it (see Field authorization).
sortUsingsortUsing(Closure(Builder $query, SortDirection $direction): void $callback): static$thisWrite 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.
isSortableisSortable(): boolboolCheck if sortable.
isSearchableisSearchable(): boolboolCheck 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

MethodSignatureReturnsDescription
rulesrules(array|Closure $rules): static$thisAdd 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.
creationRulescreationRules(array $rules): static$thisRules 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().
updateRulesupdateRules(array $rules): static$thisRules 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().
buildRulesbuildRules(?string $context = null): arrayarrayBuild the final rule array. Pass 'create' or 'update' to layer the matching context rules.
validationMessagesvalidationMessages(): arrayarrayCustom 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: 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).

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

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 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.
MethodSignatureDescription
hide / showhide(): static, show(): staticHide the field on its form (not drawn, not submitted) or show it again. Serialized as hidden.
dependsOndependsOn(array $fields, ?Closure $callback = null): staticDeclare 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).
dependentFieldsdependentFields(): list<string>Read the watched attributes.
isDependentisDependent(): boolTrue only when both $fields and a Closure are configured. The sync endpoint refuses non-reactive fields.
syncDependentsyncDependent(array $formData, Request $request): staticRun 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.

SetterSignatureLazy reader
readonlyreadonly(bool|Closure $value = true): staticisReadonly(): bool
requiredrequired(bool|Closure $value = true): staticisRequired(): bool
nullablenullable(bool|Closure $value = true): staticisNullable(): bool
defaultdefault(mixed $value): staticgetDefaultValue(): mixed
placeholderplaceholder(string|Closure $text): staticgetPlaceholder(): ?string
helphelp(string|Closure $text): staticgetHelp(): ?string
tooltiptooltip(string|Closure|null $text): staticgetTooltip(): ?string
withLabelwithLabel(string|Closure $value): staticlabel(): string
rulesrules(array|Closure $rules): staticbuildRules(): array
Select::optionsoptions(iterable|Arrayable|string|Closure $options): staticgetOptions(): list
MultiSelect::optionsoptions(iterable|Arrayable|string|Closure $options): staticgetOptions(): list
BooleanGroup::optionsoptions(array|Closure $options): staticgetOptions(): 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

MethodSignatureReturnsDescription
uniqueunique(array $config, ?string $message = null): static$thisMark as unique. Config: [table] or [table, column].
setUniqueIgnoreIdsetUniqueIgnoreId(int|string|null $id): voidvoidSet 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:

DatabaseIt reportsMartis reads
PostgreSQLthe 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
SQLitethe columns (UNIQUE constraint failed: users.email), or the index name for an expression indexthe columns, or the index name
MySQL, MariaDBthe index name only (for key 'users.users_email_unique'); PRIMARY for the primary keythe 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, these hooks do not have a static counterpart — they ARE the customization.

MethodSignatureReturnsDescription
resolveUsingresolveUsing(callable $callback): static$thisOverride value resolution. Callback: fn(mixed $value, Model $model, string $attribute, ?Request $request): mixed
fillUsingfillUsing(callable $callback): static$thisOverride model filling. Callback: fn(Model $model, mixed $value, string $attribute, ?Request $request): void
displayUsingdisplayUsing(callable|list<callable> $callback): static$thisOverride 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.
computedcomputed(?callable $callback = null): static$thisMark 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.

⭐ 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 codeStill 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:

ConcernBehaviour
FormsHidden 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() / filtersNot 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.
IntrospectionisComputed(): 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

MethodSignatureReturnsDescription
componentcomponent(string $key): static$thisOverride the React component used to render this field.
getComponentKeygetComponentKey(): ?string?stringGet custom component key (null = use default).

Metadata

MethodSignatureReturnsDescription
withMetawithMeta(array $meta): static$thisMerge 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 for the Resource-level tableLayout() switch.

MethodSignatureDescription
widthwidth(string $value): staticFix the column width (e.g. "80px", "10rem").
minWidthminWidth(string $value): staticMinimum width. Useful on title columns that would otherwise collapse.
maxWidthmaxWidth(string $value): staticMaximum width. Pair with truncate() on URL / email columns.
truncatetruncate(bool $value = true): staticClip overflow with an ellipsis. Call with false to cancel a type default.
textAligntextAlign(string $align): staticAlign 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+.
hideBelowhideBelow(string $breakpoint): staticHide 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):

FieldDefault
Idwidth 80px
Email, UrlmaxWidth 280px + truncate
Boolean, Status, Badgewidth 120px
Date, DateTimewidth 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 for details.

Serialization

MethodSignatureDescription
toArraytoArray(): arraySerialize 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>');
SignatureNotes
tooltip(?string $text): staticSets the tooltip. Pass null to clear.
getTooltip(): ?stringReturns 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), 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()

SituationUse
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 statehelp()

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()
MethodSignatureReturnsDescriptionDefault
rowsrows(int $rows): static$thisSet the row count the field opens at.5
getRowsgetRows(): intintGet 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()
MethodSignatureReturnsDescriptionDefault
minmin(int|float $min): static$thisSet minimum value (adds min:N validation rule).null
maxmax(int|float $max): static$thisSet maximum value (adds max:N validation rule).null
stepstep(int|float $step): static$thisSet stepping interval.null
integerinteger(): static$thisEnforce 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()
MethodSignatureReturnsDescriptionDefault
trueLabeltrueLabel(string $label): static$thisLabel for true value on index/detail.__('martis::messages.yes')
falseLabelfalseLabel(string $label): static$thisLabel 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();
MethodDescription
options(array|Closure)Flag key → label map. Accepts a closure that resolves at render time. See 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).

⭐ 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):

#ConditionOutput
1Stored file exists<img src={uploaded_url}>
2Developer set fallback($url | Closure)<img src={fallback}>
3Seed has initialsInline coloured initials — no external request
4Seed exists but produces no initialsMuted user-glyph chip (.martis-avatar-fallback)
5Nothing at allem-dash placeholder
MethodDescription
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:

KeyValue
initialsThe letters: the first letter of the seed's first and last words, or your initials() closure's result.
paletteThe slot (1 to 16) of the theme's --martis-avatar-N tokens the circle is painted with, or null when colorFrom() gave the colour.
colorThe literal colour: the colorFrom() value, or the slot's colour in the built-in theme.
seedThe 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 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 uploadAvatar (initials inline when empty come for free)
has no photo column at all and never willUiAvatar
is read-only / system-generated and an upload input would be noiseUiAvatar
UiAvatar::make('avatar_initials')
->from('name')
->colorFrom('brand_color')
->circle();
MethodDescription
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, 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);
MethodDescription
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.

Dropdown select with predefined options.

Select::make('status')
->options([
'draft' => 'Draft',
'published' => 'Published',
'archived' => 'Archived',
])
->nullable()
MethodSignatureReturnsDescription
optionsoptions(iterable|Arrayable|string|Closure $options): static$thisSet 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 and Closure-aware setters.
withoutOptionOrderWarningswithoutOptionOrderWarnings(): static$thisSilence 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".
displayUsingLabelsdisplayUsingLabels(): static$thisRender the option label on index and detail (default behaviour). Symmetric with MultiSelect::displayUsingLabels() for code that handles both fields generically.
displayUsingValuesdisplayUsingValues(): static$thisRender 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.
isDisplayingLabelsisDisplayingLabels(): boolboolWhether the field currently renders labels (true) or raw values (false).
getOptionsgetOptions(): arrayarrayGet normalized options [{label, value, group?}] (resolves the closure if one was set).
searchableOptionssearchableOptions(bool $value = true): static$thisRender 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.
hasSearchableOptionshasSearchableOptions(): boolboolWhether the option list renders with a search box.
allowCustomValuesallowCustomValues(bool $value = true): static$thisAccept 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).
validateAgainstOptionsvalidateAgainstOptions(bool $value = true): static$thisReject, 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.
validatesAgainstOptionsvalidatesAgainstOptions(): boolboolWhether the field rejects a value that is not one of its options.
allowsCustomValuesallowsCustomValues(): boolboolWhether the control accepts values outside the option list.
searchOptionsUsingsearchOptionsUsing(Closure $resolver): static$thisSearch 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.
hasRemoteOptionsSearchhasRemoteOptionsSearch(): boolboolWhether a server-side resolver is registered.
searchOptionssearchOptions(string $term, ?Request $request = null): arrayarrayRun 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.

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

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 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 and 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). 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). 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), 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(),
];
}
MethodSignatureReturnsDescription
makemake(string $attribute, ?string $label = null): staticstaticFactory. 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(...).
onlyonly(array $guardNames): static$thisRestrict 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.

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()
MethodSignatureReturnsDescription
optionsoptions(iterable|Arrayable|string|Closure $options): static$thisSet 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 and Closure-aware setters.
withoutOptionOrderWarningswithoutOptionOrderWarnings(): static$thisSilence 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.
displayUsingLabelsdisplayUsingLabels(): static$thisShow labels instead of raw values on index/detail.
getOptionsgetOptions(): arrayarrayGet normalized options [{label, value, group?}] (resolves the closure if one was set).
isDisplayingLabelsisDisplayingLabels(): boolboolCheck if displaying labels.
validateAgainstOptionsvalidateAgainstOptions(bool $value = true): static$thisReject, 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.
validatesAgainstOptionsvalidatesAgainstOptions(): boolboolWhether 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). 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()
MethodSignatureReturnsDescriptionDefault
withTimewithTime(bool $value = true): static$thisEnable date+time mode (changes format to Y-m-d H:i:s).false
formatformat(string $format): static$thisCustomize 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 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.
  • 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:

MethodClassWhen to use
asHeading().martis-line-headingprimary label (bold, slightly larger)
asBase().martis-line-basedefault body copy
asSmall().martis-line-smallcompact secondary text
asMuted().martis-line-mutedde-emphasised supporting text
asCode() ⭐.martis-line-codemonospace pill for slugs, IDs, tokens
subtitleFrom($attrOrClosure) ⭐—emit a muted second line pulled from another attribute, without declaring a second Line

Stack API:

MethodDescription
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(): boolIntrospection 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()
MethodSignatureReturnsDescription
displayTextdisplayText(string $text): static$thisStatic display text for the link. For dynamic text, use displayUsing().
getDisplayTextgetDisplayText(): ?string?stringGet 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()
MethodSignatureReturnsDescriptionDefault
withFlagswithFlags(): static$thisEnable flag emoji display. Martis extension.false
withoutFlagswithoutFlags(): static$thisDisable flag emoji display.—
hasFlagshasFlags(): boolboolCheck if flags enabled.—
countryListstatic countryList(): arrayarrayFull ISO 3166-1 country list [{label, value, flag}].—
resolveCountryNamestatic resolveCountryName(string $code): ?string?stringGet country name from 2-letter code.—
resolveCountryFlagstatic resolveCountryFlag(string $code): ?string?stringGet 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')
MethodSignatureReturnsDescriptionDefault
currencycurrency(CurrencyCode $code): static$thisSet ISO 4217 currency code (typed enum). Auto-sets step.CurrencyCode::USD
localelocale(string $locale): static$thisFixes the display and input locale of this field (e.g. de_DE), whatever the user's Martis locale.the user's Martis locale
asMinorUnitsasMinorUnits(): static$thisTreat stored value as minor units (cents).false
asMajorUnitsasMajorUnits(): static$thisTreat stored value as major units (dollars).—
displayModedisplayMode(CurrencyDisplayMode $mode): static$thisSet display mode (typed enum: Text, Badge, BadgeText). Martis extension.CurrencyDisplayMode::Text
showBadgeshowBadge(): static$thisShortcut for displayMode(CurrencyDisplayMode::Badge).—
showTextshowText(): static$thisShortcut for displayMode(CurrencyDisplayMode::Text).—
showBadgeTextshowBadgeText(): static$thisShortcut for displayMode(CurrencyDisplayMode::BadgeText).—
badgeColorbadgeColor(string $color): static$thisSet badge color. Martis extension.null
getCurrencyCodegetCurrencyCode(): CurrencyCodeCurrencyCodeGet the currency enum case.—
getLocalegetLocale(): stringstringGet effective locale.—
isMinorUnitsisMinorUnits(): boolboolCheck if using minor units.—
getDisplayModegetDisplayMode(): CurrencyDisplayModeCurrencyDisplayModeGet display mode enum.—
getBadgeColorgetBadgeColor(): ?string?stringGet badge color.—
getCurrencyInfogetCurrencyInfo(): arrayarrayGet {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, MorphToMany, or 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)
MethodSignatureReturnsDescriptionDefault
titleAttributetitleAttribute(string $attribute): static$thisAttribute 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)
displayColumndisplayColumn(string $column): static$thisAlias for titleAttribute(). Sets which column appears in index/table cells.as titleAttribute
foreignKeyforeignKey(string $key): static$thisOverride FK column name.{relationship}_id
relatedResourcerelatedResource(string $uriKey): static$thisURI 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.null
placeholderplaceholder(string|\Closure $text): static$thisCustom placeholder shown when no value is selected. Closure receives (?Request $r) for per-request resolution.translated 'Select {field}...'
relationSearchablerelationSearchable(bool $value = true): static$thisEnable/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
relatableQueryUsingrelatableQueryUsing(\Closure $closure): static$thisPer-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
displayAsLinkdisplayAsLink(bool $value = true): static$thisRender as clickable link on index/detail.true
showCreateRelationButtonshowCreateRelationButton(bool|\Closure $callback = true): static$thisShow "+" button to create related record inline via modal.false
hideCreateRelationButtonhideCreateRelationButton(): static$thisExplicitly hide the inline create button.—
modalSizemodalSize(ModalSize $size): static$thisSet the inline create modal size. Pass any Martis\Enums\ModalSize case (Small, Medium, Large, ExtraLarge, TwoExtraLarge through SevenExtraLarge).ModalSize::TwoExtraLarge
iconColoriconColor(string $color): static$thisColor for the resource icon in the inline create modal header. Any CSS color.accent color
inverseinverse(string $relationship): static$thisThe 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.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 inIt asks
a resource formGET /api/resources/{resource}/{id}/relatable/{attribute} ({id} is the record under edit, _ on a create form)
an Action modalGET /api/resources/{resource}/actions/{action}/relatable/{attribute}
a pivot action modalGET /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, Relation pickers among the pivot fields and 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).

MethodSignatureReturnsDescriptionDefault
peekablepeekable(bool $value = true): static$thisEnable/disable the peek preview icon.true
noPeekingnoPeeking(): static$thisDisable 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. 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()
MethodSignatureReturnsDescriptionDefault
relatedResourcerelatedResource(string $uriKey): static$thisURI key of related resource for autocomplete API.null
titleAttributetitleAttribute(string $attribute): static$thisDisplay 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)
withPreviewwithPreview(): static$thisEnable preview popover on hover.false
displayAsListdisplayAsList(): static$thisRender as vertical list instead of chips.false
showCreateRelationButtonshowCreateRelationButton(): static$thisShow inline create button.false
modalSizemodalSize(string $size): static$thisInline creation modal size (sm to 7xl).'2xl'
preloadpreload(): static$thisPreload all tags on init (for small sets).false
relationSearchablerelationSearchable(bool $value = true): static$thisEnable/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
getRelationshipgetRelationship(): stringstringGet relationship method name.—
getTitleAttributegetTitleAttribute(): stringstringGet title attribute.—
getRelatedResourcegetRelatedResource(): ?string?stringGet related resource URI key.—
hasPreviewhasPreview(): boolboolCheck if preview enabled.—
isDisplayAsListisDisplayAsList(): boolboolCheck if list mode.—
isShowCreateRelationButtonisShowCreateRelationButton(): boolboolCheck if create button shown.—
getModalSizegetModalSize(): stringstringGet modal size.—
isPreloadisPreload(): boolboolCheck 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). 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 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 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)
MethodSignatureReturnsDescriptionDefault
relatedResourcerelatedResource(string $uriKey): static$thisURI key of related resource.inferred from relationship
titleAttributetitleAttribute(string $attribute): static$thisAttribute 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)
fieldsfields(Closure(): list<Field>): static$thisDefine pivot fields (stored on the pivot table).null
actionsactions(Closure(Request): list<Action>): static$thisPivot actions for this relationship's panel only; handle() receives the selected attached records, each with its pivot row. See Actions → Pivot Actions.null
searchablesearchable(bool $value = true): static$thisEnable search in the attach modal.false
collapsablecollapsable(bool $value = true): static$thisMake the panel collapsable.false
collapsedByDefaultcollapsedByDefault(bool $value = true): static$thisStart the panel collapsed. Implies collapsable.false
allowDuplicateRelationsallowDuplicateRelations(bool $value = true): static$thisAllow attaching the same record multiple times.false
showCreateRelationButtonshowCreateRelationButton(bool|Closure $callback = true): static$thisShow inline create button in attach modal.false
hideCreateRelationButtonhideCreateRelationButton(): static$thisExplicitly hide the inline create button.—
modalSizemodalSize(ModalSize|string $size): static$thisAttach modal size (sm, md, lg, xl, 2xl–7xl).'2xl'
relatableQueryUsingrelatableQueryUsing(Closure $fn): static$thisFilter the query for attachable records.null
dontReorderAttachablesdontReorderAttachables(bool $value = true): static$thisDisable auto-sort of attachables (keep DB order).false
withSubtitleswithSubtitles(bool $value = true): static$thisShow subtitles in the attach modal search results.false
perPageperPage(int $perPage): static$thisDefault per-page for the inline listing.10
canAttachcanAttach(bool $value = true): static$thisAllow 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
canDetachcanDetach(bool $value = true): static$thisAllow detaching records through the panel. Off hides the Detach button and the detach endpoint answers 403 (v2.4.0).true

API Endpoints

MethodPathAction
GET/api/resources/{resource}/{id}/belongs-to-many/{relationship}List attached records (paginated)
GET/api/resources/{resource}/{id}/belongs-to-many/{relationship}/attachableList records available to attach
POST/api/resources/{resource}/{id}/belongs-to-many/{relationship}/attachAttach a record (with optional pivot data)
DELETE/api/resources/{resource}/{id}/belongs-to-many/{relationship}/{relatedId}/detachDetach a record
PUT/api/resources/{resource}/{id}/belongs-to-many/{relationship}/{relatedId}/pivotUpdate 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.

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.

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.

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. 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)
MethodSignatureReturnsDescriptionDefault
relatedResourcerelatedResource(string $uriKey): static$thisURI key of the related resource.inferred from relationship
canCreatecanCreate(bool $value = true): static$thisAllow creating the related record through the panel. Off hides the Create button and the create endpoint answers 403 (v2.4.0).true
canUpdatecanUpdate(bool $value = true): static$thisAllow editing the related record through the panel. Off hides the Edit button and the update endpoint answers 403 (v2.4.0).true
canDeletecanDelete(bool $value = true): static$thisAllow 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 field. src/Fields/HasOne.php

Toolbar controls (inherited)

All nine hideXxx() setters from ControlsRelationshipToolbar are inherited — see relationships.md § Toolbar hide flags. 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 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');
MethodSignatureReturnsDescriptionDefault
All HasOne setters—$thisInherited.—
latestByTimestamplatestByTimestamp(string $column = 'created_at'): static$thisOrders 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'
oldestByTimestampoldestByTimestamp(string $column = 'created_at'): static$thisAscending counterpart of latestByTimestamp(); a tie goes to the lowest primary key.'created_at'
aggregateViaaggregateVia(AggregateFunction $function, string $column = '*'): static$thisEmits 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. 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 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).

use Martis\Fields\HasOneThrough;
 
HasOneThrough::make('Account Manager', 'accountManager', TeamMemberResource::class)
->throughBreadcrumb();
MethodSignatureReturnsDescriptionDefault
All HasOne setters—$thisInherited, except canCreate, which has no effect (always false; canCreate(true) logs a warning naming the field).—
throughBreadcrumbthroughBreadcrumb(bool $enabled = true, ?string $text = null): static$thisAdds 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. 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 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);
MethodSignatureReturnsDescriptionDefault
relatedResourcerelatedResource(string $uriKey): static$thisURI key of the related resource.inferred from relationship
perPageperPage(int $perPage): static$thisDefault per-page for the inline listing.10
perPageOptionsperPageOptions(array $options): static$thisCustom per-page selector options.resolved from related resource / [5,10,25,50]
canCreatecanCreate(bool $value = true): static$thisAllow creating a related record through the panel. Off hides the Create button and the create endpoint answers 403 (v2.4.0).true
canUpdatecanUpdate(bool $value = true): static$thisAllow editing a related record through the panel. Off hides the Edit action and the update endpoint answers 403 (v2.4.0).true
canDeletecanDelete(bool $value = true): static$thisAllow deleting a related record through the panel. Off hides the Delete action and the delete endpoint answers 403 (v2.4.0).true
relationSearchablerelationSearchable(bool $value = true): static$thisEnable the search input in the panel toolbar.false
indexDisplayindexDisplay(HasManyIndexDisplay $mode): static$thisConfigure how the field renders when shown on the index page.—
showRelationIconshowRelationIcon(bool $value = true): static$thisShow the related-resource icon in the panel heading.true
showRelationCountshowRelationCount(bool $value = true): static$thisShow the total count badge in the panel heading.true
badgeColorbadgeColor(string $color): static$thisOverride the count badge colour for index display.—
badgeIconbadgeIcon(string $icon): static$thisOverride the panel icon.—
redirectAfterSaveredirectAfterSave(HasManyRedirectMode $mode): static$thisWhere to redirect after saving a related record.—
collapsablecollapsable(bool $value = true): static$thisMake the panel collapsable.false
collapsedByDefaultcollapsedByDefault(bool $value = true): static$thisStart 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. 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 for the full guide and 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).

use Martis\Fields\HasManyThrough;
 
HasManyThrough::make('Managed Projects', 'managedProjects', ProjectResource::class)
->throughBreadcrumb()
->countBadge();
MethodSignatureReturnsDescriptionDefault
All HasMany setters—$thisInherited, except canCreate, which has no effect (always false; canCreate(true) logs a warning naming the field).—
throughBreadcrumbthroughBreadcrumb(bool $enabled = true, ?string $text = null): static$thisAdds a "through" hint next to the section heading.false
countBadgecountBadge(bool $enabled = true): static$thisRenders 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. 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 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();
MethodSignatureReturnsDescriptionDefault
relatedResourcerelatedResource(string $uriKey): static$thisURI key of the related resource.inferred from relationship
canCreatecanCreate(bool $value = true): static$thisAllow creating a related record through the panel. Off hides the Create button and the create endpoint answers 403 (v2.4.0).true
canUpdatecanUpdate(bool $value = true): static$thisAllow editing the related record through the panel. Off hides the Edit button and the update endpoint answers 403 (v2.4.0).true
canDeletecanDelete(bool $value = true): static$thisAllow 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. 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 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, '*');
MethodSignatureReturnsDescriptionDefault
All MorphOne setters—$thisInherited.—
latestByTimestamplatestByTimestamp(string $column = 'created_at'): static$thisOrders by the timestamp descending before picking the first row; a tie goes to the highest primary key.'created_at'
oldestByTimestampoldestByTimestamp(string $column = 'created_at'): static$thisAscending counterpart; a tie goes to the lowest primary key.'created_at'
aggregateViaaggregateVia(AggregateFunction $function, string $column = '*'): static$thisEmits 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. 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 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);
MethodSignatureReturnsDescriptionDefault
relatedResourcerelatedResource(string $uriKey): static$thisURI key of the related resource.inferred from relationship
perPageperPage(int $perPage): static$thisDefault per-page for the inline listing.10
perPageOptionsperPageOptions(array $options): static$thisCustom per-page selector options.resolved from related resource / [5,10,25,50]
canCreatecanCreate(bool $value = true): static$thisAllow creating a related record through the panel. Off hides the Create button and the create endpoint answers 403 (v2.4.0).true
canUpdatecanUpdate(bool $value = true): static$thisAllow editing a related record through the panel. Off hides the Edit action and the update endpoint answers 403 (v2.4.0).true
canDeletecanDelete(bool $value = true): static$thisAllow deleting a related record through the panel. Off hides the Delete action and the delete endpoint answers 403 (v2.4.0).true
relationSearchablerelationSearchable(bool $value = true): static$thisEnable the search input in the panel toolbar.false
indexDisplayindexDisplay(HasManyIndexDisplay $mode): static$thisConfigure how the field renders when shown on the index page.—
showRelationIconshowRelationIcon(bool $value = true): static$thisShow the related-resource icon in the panel heading.true
showRelationCountshowRelationCount(bool $value = true): static$thisShow the total count badge in the panel heading.true
badgeColorbadgeColor(string $color): static$thisOverride the count badge colour.—
badgeIconbadgeIcon(string $icon): static$thisOverride the panel icon.—
redirectAfterSaveredirectAfterSave(HasManyRedirectMode $mode): static$thisWhere 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. 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 for the full guide and 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();
MethodSignatureReturnsDescriptionDefault
typestypes(array $resourceClasses): static$thisAllowed resource families for this polymorphic relationship. Each entry is a Resource class name; the model class is derived via newModel().—
titleAttributetitleAttribute(string $attr): static$thisAttribute used for the display label in the picker and on detail rows.'name'
relationSearchablerelationSearchable(bool $value = true): static$thisEnable / 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
showCreateRelationButtonshowCreateRelationButton(bool|\Closure $callback = true): static$thisShow the inline "+" button that creates a related record per type.false
hideCreateRelationButtonhideCreateRelationButton(): static$thisExplicitly hide the inline create button.—
modalSizemodalSize(ModalSize $size): static$thisModal size for the inline create flow. Pass any Martis\Enums\ModalSize case (Small through SevenExtraLarge).ModalSize::TwoExtraLarge
nullablenullable(): static$thisMake 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. 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).

use Martis\Fields\MorphToMany;
 
MorphToMany::make('Tags', 'tags', TagResource::class)
->titleAttribute('name')
->searchable()
->collapsable()
->fields(fn () => [
Text::make('notes', 'Notes')->nullable(),
]);
MethodSignatureReturnsDescriptionDefault
relatedResourcerelatedResource(string $uriKey): static$thisURI key of the related resource.inferred from relationship
titleAttributetitleAttribute(string $attribute): static$thisDisplay 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)
fieldsfields(Closure $closure): static$thisDefine pivot fields (stored on the morph-pivot table).—
actionsactions(Closure(Request): list<Action>): static$thisPivot actions for this relationship's panel only; handle() receives the selected attached records, each with its pivot row. See Actions → Pivot Actions.—
searchablesearchable(bool $value = true): static$thisEnable search in the attach modal.false
collapsablecollapsable(bool $value = true): static$thisMake the panel collapsable.false
collapsedByDefaultcollapsedByDefault(bool $value = true): static$thisStart collapsed. Implies collapsable.false
allowDuplicateRelationsallowDuplicateRelations(bool $value = true): static$thisAllow attaching the same record multiple times.false
showCreateRelationButtonshowCreateRelationButton(bool|Closure $callback = true): static$thisShow the inline create button in the attach modal.false
hideCreateRelationButtonhideCreateRelationButton(): static$thisExplicitly hide the inline create button.—
modalSizemodalSize(ModalSize $size, ?string $height = null): static$thisAttach modal size / optional height.'2xl'
relatableQueryUsingrelatableQueryUsing(Closure $closure): static$thisFilter the attachable record list.—
dontReorderAttachablesdontReorderAttachables(bool $value = true): static$thisKeep DB order in the attachable list.false
withSubtitleswithSubtitles(bool $value = true): static$thisShow subtitles in search results.false
subtitleAttributesubtitleAttribute(string $attribute): static$thisColumn used as the subtitle.—
perPageperPage(int $perPage): static$thisDefault per-page for the inline listing.10
perPageOptionsperPageOptions(array $options): static$thisCustom per-page selector options.resolved from related resource / [5,10,25,50]
canAttachcanAttach(bool $value = true): static$thisAllow 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
canDetachcanDetach(bool $value = true): static$thisAllow 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).

src/Fields/MorphToMany.php

Toolbar controls (inherited)

All nine hideXxx() setters from ControlsRelationshipToolbar are inherited — see relationships.md § Toolbar hide flags. 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 for the full guide and 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()
MethodSignatureReturnsDescriptionDefault
diskdisk(string $disk): static$thisSet Laravel filesystem disk.'public'
getDiskgetDisk(): stringstringGet disk name.—
storagePathstoragePath(string $path): static$thisSet subdirectory within disk.'uploads'
maxSizemaxSize(int $kb): static$thisSet max file size in KB.null
acceptedTypesacceptedTypes(array $mimes): static$thisRestrict accepted file extensions. A security control, see Active content: listing an active type ('svg', 'html') accepts it.[]
allowActiveContentallowActiveContent(bool $value = true): static$thisAccept HTML, SVG, XML and script files, which are refused by default. v2.4.0+.false
allowsActiveContentallowsActiveContent(): boolboolWhether the field accepts active content.—
multiplemultiple(bool $value = true): static$thisEnable multiple file uploads (stores JSON array).false
isMultipleisMultiple(): boolboolCheck if multiple mode.—
preserveOriginalNamepreserveOriginalName(bool $value = true): static$thisKeep original filename (with unique suffix).false
sanitizeFileNamesanitizeFileName(bool|callable $sanitizer = true): static$thisSanitize filenames. Accepts true for default or a custom callable.false
showFileInfoshowFileInfo(bool $value = true): static$thisShow/hide file info (max size, types).true
hideFileInfohideFileInfo(): static$thisHide file info display.—
deleteStoredFiledeleteStoredFile(Model $model): voidvoidDelete stored file(s) from disk.—
buildItemRulesbuildItemRules(): arrayarrayValidation 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), 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()
MethodSignatureReturnsDescriptionDefault
thumbnailthumbnail(int $width = 300, int $height = 300): static$thisEnable 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): 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'])
MethodSignatureReturnsDescriptionDefault
jsonjson(): static$thisTreat as JSON: pretty-print on resolve, decode on fill.false
languagelanguage(CodeLanguage $language): static$thisSet syntax-highlighting language (typed enum).CodeLanguage::Javascript
isJsonisJson(): boolboolCheck if JSON mode.—
getLanguagegetLanguage(): CodeLanguageCodeLanguageGet 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')
MethodSignatureReturnsDescriptionDefault
alwaysShowalwaysShow(): static$thisAlways expand content on detail (skip "Show Content" toggle).false
presetpreset(string $preset): static$thisMarkdown rendering preset: 'default' (GFM), 'commonmark', 'zero'.'default'
withFileswithFiles(?string $disk = null): static$thisEnable 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.disabled
isAlwaysShowisAlwaysShow(): boolboolCheck if always showing.—
getPresetgetPreset(): stringstringGet preset.—
getWithFilesDiskgetWithFilesDisk(): ?string?stringThe 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')
MethodSignatureReturnsDescriptionDefault
alwaysShowalwaysShow(): static$thisAlways expand content on detail.false
withFileswithFiles(?string $disk = null): static$thisEnable 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.disabled
toolbarSizetoolbarSize(string $size): static$thisToolbar button size: 'sm', 'md', 'lg'.null
isAlwaysShowisAlwaysShow(): boolboolCheck if always showing.—
getWithFilesDiskgetWithFilesDisk(): ?string?stringThe 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()
MethodSignatureReturnsDescriptionDefault
keyLabelkeyLabel(string $label): static$thisLabel for key column header.'Key'
valueLabelvalueLabel(string $label): static$thisLabel for value column header.'Value'
actionTextactionText(string $text): static$thisLabel for "add row" button.'Add Row'
disableEditingKeysdisableEditingKeys(): static$thisPrevent editing existing keys; a write drops any key outside the stored (or default) key set (v2.4.0+).false
disableAddingRowsdisableAddingRows(): static$thisPrevent adding new rows; a write drops a new row but keeps a renamed key (v2.4.0+).false
disableDeletingRowsdisableDeletingRows(): static$thisPrevent 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
getKeyLabelgetKeyLabel(): stringstringGet key label.—
getValueLabelgetValueLabel(): stringstringGet value label.—
getActionTextgetActionText(): stringstringGet action text.—
isEditingKeysDisabledisEditingKeysDisabled(): boolboolCheck if key editing disabled.—
isAddingRowsDisabledisAddingRowsDisabled(): boolboolCheck if adding disabled.—
isDeletingRowsDisabledisDeletingRowsDisabled(): boolboolCheck 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):

FlagWhat 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). 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')
MethodSignatureReturnsDescription
contentcontent(string $text): static$thisDescriptive 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 and 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',
])
MethodSignatureReturnsDescriptionDefault
mapmap(array|Closure $map): static$thisValue → badge type. Closure resolves once at schema build.[]
labelslabels(array|Closure $labels): static$thisValue → translated display label. Closure form supported.[]
typestypes(array|Closure $types): static$thisOverride badge type definitions (replaces defaults).info, success, warning, danger
addTypesaddTypes(array $types): static$thisMerge extra badge types onto the current set.—
withIconswithIcons(): static$thisEnable icon rendering in badges.false
iconsicons(array|Closure $icons): static$thisBadge type → icon name (also enables icons).[]
resolveBadgeUsing ⭐resolveBadgeUsing(Closure $fn): static$thisPer-row override — closure receives (value, model) and returns ['type', 'label', 'icon'].—
getMapgetMap(): arrayarrayGet resolved value-to-type map.—
getTypesgetTypes(): arrayarrayGet resolved type definitions.—
hasIconshasIcons(): boolboolCheck if icons enabled.—
getIconsgetIcons(): arrayarrayGet 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'])
MethodSignatureReturnsDescriptionDefault
loadingWhenloadingWhen(array $values): static$thisValues that trigger spinner.[]
failedWhenfailedWhen(array $values): static$thisValues that trigger error indicator.[]
getLoadingWhengetLoadingWhen(): arrayarrayGet loading values.—
getFailedWhengetFailedWhen(): arrayarrayGet 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)
MethodSignatureReturnsDescriptionDefault
squaredsquared(): static$thisDisplay with square edges.—
roundedrounded(): static$thisDisplay with rounded (circle) edges.'rounded'
sizesize(int $size): static$thisAvatar size in pixels.40
getShapegetShape(): stringstringGet shape ('rounded' or 'squared').—
getSizegetSize(): intintGet size in pixels.—
gravatarUrlstatic gravatarUrl(string $email, int $size = 40): stringstringGenerate 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')
MethodSignatureReturnsDescriptionDefault
datadata(array|callable $data): static$thisSet chart data (array of numbers or callable receiving model).null
asBarChartasBarChart(): static$thisRender as bar chart.—
asLineChartasLineChart(): static$thisRender as line chart.'line'
heightheight(int $px): static$thisChart height in pixels.30
chartWidthchartWidth(int $px): static$thisSVG canvas width in pixels. Renamed from width() — the base Field::width(string) now controls the index column width.null
colorcolor(string $color): static$thisChart line/bar color (CSS color).'#6366f1'
maxPointsmaxPoints(int $max): static$thisMost numbers a written series may hold (validated when the field is shown on a form).1000
getMaxPointsgetMaxPoints(): intintGet the limit.—
getChartTypegetChartType(): stringstringGet chart type.—
getChartHeightgetChartHeight(): intintGet height.—
getChartWidthgetChartWidth(): ?int?intGet width.—
getChartColorgetChartColor(): stringstringGet 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 for the full API. Highlights:

MethodSignatureDescription
repeatablesrepeatables(array<Repeatable>)Register the row types available in the Add menu
asJsonasJson(): staticPersist on a JSON-cast parent attribute
asHasManyasHasMany(): staticPersist via a child table with 3-way upsert
asPolymorphic ⭐asPolymorphic(string $type = 'type', string $payload = 'payload')One child table for every row type
uniqueFielduniqueField(string)Column used to identify rows across saves
confirmRemovalconfirmRemoval(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.

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.


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

MethodSignatureDescription
registerstatic register(Model $model, string $relationship, array $ids): voidRegister a relationship sync to be executed after save.
syncstatic sync(Model $model): voidExecute 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

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