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
- Base Field (Common Methods)
- Factory
- Identity
- Value Resolution & Filling
- Fluent Configuration
- Visibility
- Granular Visibility
- Convenience Visibility Presets
- Context-Aware Visibility
- Field authorization:
canSee()andcanSeeForModel() - Sortable / Searchable
- Validation
- Unique Validation
- Customization Hooks
- Component Override
- Metadata
- Serialization
- Tooltips (Martis differential)
- Field Types
- Id
- Text
- Textarea
- Number
- Boolean
- BooleanGroup
- Avatar
- UiAvatar
- Audio
- Select
- GuardSelect
- MultiSelect
- Date
- DateTime
- Password
- PasswordConfirmation
- Slug
- Timezone
- Icon
- Stack + Line
- Url
- Color
- Country
- Currency
- BelongsTo
- Tag
- BelongsToMany
- HasOne
- HasOneOfMany
- HasOneThrough
- HasMany
- HasManyThrough
- MorphOne
- MorphOneOfMany
- MorphMany
- MorphTo
- MorphToMany
- File
- Image
- Code
- Markdown
- Trix
- KeyValue
- Heading
- Hidden
- Badge
- Badges
- Status
- Gravatar
- Sparkline
- Utility Classes
Import
use Martis\Fields\Badge;use Martis\Fields\BelongsTo;use Martis\Fields\Audio;use Martis\Fields\Avatar;use Martis\Fields\Boolean;use Martis\Fields\BooleanGroup;use Martis\Fields\Code;use Martis\Fields\Color;use Martis\Fields\Country;use Martis\Fields\Currency;use Martis\Fields\Date;use Martis\Fields\DateTime;use Martis\Fields\Email;use Martis\Fields\File;use Martis\Fields\Gravatar;use Martis\Fields\Heading;use Martis\Fields\Hidden;use Martis\Fields\Icon;use Martis\Fields\Id;use Martis\Fields\Image;use Martis\Fields\KeyValue;use Martis\Fields\Line;use Martis\Fields\Markdown;use Martis\Fields\MultiSelect;use Martis\Fields\Number;use Martis\Fields\Password;use Martis\Fields\PasswordConfirmation;use Martis\Fields\Select;use Martis\Fields\Slug;use Martis\Fields\Sparkline;use Martis\Fields\Stack;use Martis\Fields\Status;use Martis\Fields\Tag;use Martis\Fields\Text;use Martis\Fields\Textarea;use Martis\Fields\Timezone;use Martis\Fields\Trix;use Martis\Fields\UiAvatar;use Martis\Fields\Url;Base Field (Common Methods)
All fields extend Martis\Fields\Field (abstract) which implements Martis\Contracts\FieldContract. These methods are available on every field type.
Factory
| Method | Signature | Description |
|---|---|---|
make | static make(string $attribute, ?string $label = null): static | Create a new field instance. Label defaults to title-cased attribute name. |
Text::make('title') // label = "Title"Text::make('first_name', 'First Name') // explicit labelIdentity
| Method | Signature | Description |
|---|---|---|
attribute | attribute(): string | Return the model attribute name (e.g. "title"). |
label | label(): string | Return the human-readable label (e.g. "Title"). |
type | type(): string | Return the field type identifier (e.g. "text", "boolean"). Abstract — each subclass implements. |
Value Resolution & Filling
| Method | Signature | Description |
|---|---|---|
resolve | resolve(Model $model, ?string $attribute = null): mixed | Read the field value from a model: the raw value comes from the computed() callback when set, from $model->getAttribute() otherwise, then the resolveUsing() callback runs if set. |
resolveForDisplay | resolveForDisplay(Model $model, ?string $attribute = null): mixed | Resolve then apply displayUsing() callback. Use for index/detail serialization. |
fill | fill(Model $model, mixed $value): void | Write a value to the model. Respects the fillUsing() callback, the readonly flag and computed() (a computed field writes nothing unless a fillUsing() callback translates it), on every built-in field (Repeater honours both since v1.38.0, and applies them, canSee() and immutable() to the fields inside its rows, see Repeater). |
hasStructuredValue | hasStructuredValue(): bool | Whether the form submits this field's value as a list or a map rather than a scalar. true on Repeater, MultiSelect, BooleanGroup, KeyValue, Tag, MorphTo and Sparkline; override it on a custom field whose form value is structured. See Structured values and file uploads. |
rejectsUnstructuredValue | rejectsUnstructuredValue(): bool | Whether a value that is not a list or a map (a string that is not JSON for one, a number, a boolean) fails validation instead of reaching fill(). true for a structured field the package fills itself; false for a readonly or computed field, a field with a fillUsing() callback, and MorphTo (its fill() ignores anything that is not a target map). Override it on a custom field whose fill() ignores such a value. v1.38.0+. |
Structured values and file uploads
A form that uploads a file (a new File / Image was picked) is sent as multipart/form-data, and FormData carries strings and files only. The SPA therefore encodes every non-file value on that path in a shape the server reads back exactly as the JSON request path would send it:
| Form value | Multipart body | Server side |
|---|---|---|
File | the file | $request->file() |
null / undefined | '' | null (Laravel's ConvertEmptyStringsToNull), so a field can be cleared |
true / false | '1' / '0' | what Boolean::fill() and the boolean rule expect |
| array or plain object | JSON.stringify(value) | decoded back to the array before validation and fill for every field whose hasStructuredValue() is true |
| other scalar | String(value) | as is |
The decoding runs in every resource controller (ResourceController, HasMany / HasOne / MorphMany / MorphOne inline forms) through DecodesStructuredValues, so a Repeater's rows, a MultiSelect's selection, a BooleanGroup's flag map, a KeyValue's pairs, a Tag's ids, a MorphTo's target or a Sparkline's points survive a save that also uploads a file, validation rules such as array / max:N see the real list, and fill() receives the same value it gets on a JSON save.
A value that is not a list or a map is never stored: a non-empty string that does not decode to one ("[object Object]", "12,15", a JSON scalar) on either path, and a number or a boolean a JSON request sends ({"meta": 5, "labels": true}). The controller validates it with Laravel's array rule on top of the field's own rules, so the request fails with a 422 (The <label> field must be an array.) and the stored value stays as it was, whether or not the field declares an array rule. This holds on both request paths for every field whose rejectsUnstructuredValue() is true, so a stale SPA bundle or a hand-written API call cannot empty a Repeater, clear a KeyValue or MultiSelect, detach every Tag or store the text in a BooleanGroup column. It does not apply where no data is at risk: a readonly or computed field writes nothing, a fillUsing() callback decides which shapes it accepts, and a MorphTo ignores any value that is not a target map (an update form up to v1.37.3 sends a bare id for an untouched target, and a morph key can be a string). null and the empty string (what the multipart path sends for null) still clear the field. Since v1.38.0; in v1.37.3 only a field with its own array rule was protected from a string, and nothing stopped a number or a boolean sent on the JSON path from reaching fill() and emptying the field.
Custom fields whose form value is a list or a map should return true from hasStructuredValue() to join that path; a field whose value is a string that happens to look like JSON (a Code editor holding JSON, a Textarea) must leave it false so its text is never decoded.
Before v1.37.3 the multipart body carried String(value) for everything: structured values arrived as "[object Object]" / "12,15" (rows silently emptied, selections cleared or rejected with 422, maps stored as text) and an unchecked Boolean arrived as "false", which PHP casts to true.
Structured values and Eloquent casts
MultiSelect, KeyValue and the multiple-file modes of File / Image persist a list or a map. Their fill() looks at the model before deciding who serialises it:
- Uncast column (a plain
string/text/jsoncolumn 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(...), anyCastable): 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:
// Modelprotected $casts = ['publish_states' => 'array']; // ResourceMultiSelect::make('publish_states')->options([...]);// Model: no cast on `publish_states`// ResourceMultiSelect::make('publish_states')->options([...]); // $model->publish_states is the JSON string; resolve() decodes it for the formBefore v1.37.3 a cast attribute was double-encoded (a JSON string of a JSON string) on the first save through the admin form, and every later read through the cast returned a string.
Fluent Configuration
| Method | Signature | Returns | Description |
|---|---|---|---|
nullable | nullable(bool|Closure $value = true): static | $this | Mark as nullable (adds nullable validation rule). Accepts a closure for request-time resolution. |
sensitive | sensitive(bool $value = true): static | $this | Mark the value as a secret: an Action's field that collects an API key or a token. The action event log stores ****** for it instead of the value. Password and PasswordConfirmation are sensitive by default. See Sensitive fields. v2.4.0+. |
isSensitive | isSensitive(): bool | bool | Whether the value is a secret. |
readonly | readonly(bool|Closure $value = true): static | $this | Prevent modification through UI. fill() becomes a no-op. Accepts a closure for request-time resolution. Every bundled input renders the field read-only (Avatar, BooleanGroup, Repeater, File, Image and the inline-create "+" of BelongsTo / MorphTo since v1.38.0, see Immutable fields). 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). |
required | required(bool|Closure $value = true): static | $this | Require a non-null value (adds required validation rule). Accepts a closure for request-time resolution. v1.8.3: declaring 'required' inside ->rules([...]) is enough, the visual asterisk auto-detects it. Calling ->required() explicitly is still supported and required when you want a Closure-resolved flag. v2.10.0: only the exact required rule (or a rule object that renders as exactly required, Rule::requiredIf(true)) counts, as in Nova. A conditional rule (required_if:…, required_with, required_unless, required_array_keys, …) is evaluated when the request is validated: it neither adds the asterisk nor makes the field unconditionally required. See Conditional required rules. |
placeholder | placeholder(string|Closure $text): static | $this | Set placeholder text for the input. Accepts a closure for request-time resolution. |
help | help(string|Closure $text): static | $this | Set help text displayed below the field input. Supports inline HTML (Martis extension): the panel sanitises it (links, bold, code, lists and line breaks stay; scripts, event handlers, javascript: URLs, the style attribute and element, id, name, forms and form controls are removed), but it is still output you author, so never interpolate unescaped user or record data into it (use e()). Accepts a closure for request-time resolution. |
tooltip | tooltip(string|Closure|null $text): static | $this | ⭐ Martis differential. Attach a hover tooltip to the field label — shown via a (?) icon next to the label. Supports raw HTML so authors can use <br />, <strong>, <em>, <ul>, etc. for multi-line rich hints. Accepts a closure for request-time resolution. Pass null to clear. See Tooltips. |
withLabel | withLabel(string|Closure $value): static | $this | Override the constructor label after construction. Accepts a closure for request-time resolution. |
fullWidth | fullWidth(bool $fullWidth = true): static | $this | Make the field span the full width of the form. On the detail page and in the detail drawer the row becomes a single column with the label above the value (v2.23.0+; before, the flag was ignored there). |
hideLabelOnDetail | hideLabelOnDetail(bool $hide = true): static | $this | Hide the field label on the detail page and in the detail drawer, so a long value uses the whole row. The label stays in the markup for screen readers; forms keep it. Implies the single-column row of fullWidth(). v2.23.0+. |
stacked | stacked(bool $stacked = true): static | $this | Control label position on forms: stacked above (true) or inline (false). The detail rows do not read it (it defaults to true); use fullWidth() there. |
default | default(mixed $value): static | $this | Set a default value for the field on create forms. Accepts a closure for request-time resolution, resolved on the server. The create page, the create drawer, the inline create modal and the action modals open the field on it (v2.16.0+; before, only a Repeater row and the attach modal's pivot fields did). A copy made with Replicate opens on the copied record's values instead. |
sticky | sticky(bool $value = true): static | $this | Keep the value the user submitted when they choose "Create & add another", so the next record starts from it instead of the field's default; the other fields return to their defaults. Serialised as sticky in the field descriptor. Has no effect on update, replicate or the other submit buttons. |
Visibility
| Method | Signature | Returns | Description |
|---|---|---|---|
showOnIndex | showOnIndex(): static | $this | Show on the index (list) view. |
hideFromIndex | hideFromIndex(): static | $this | Hide from the index view. |
showOnDetail | showOnDetail(): static | $this | Show on the detail (show) view. |
hideFromDetail | hideFromDetail(): static | $this | Hide from the detail view. |
showOnForms | showOnForms(): static | $this | Show on create and edit forms. |
hideFromForms | hideFromForms(): static | $this | Hide from create and edit forms. |
isShownOnIndex | isShownOnIndex(): bool | bool | Check if visible on index. |
isShownOnDetail | isShownOnDetail(): bool | bool | Check if visible on detail. |
isShownOnForms | isShownOnForms(): bool | bool | Check if visible on forms. |
Granular Visibility
| Method | Signature | Returns | Description |
|---|---|---|---|
hideWhenCreating | hideWhenCreating(): static | $this | Hide on create form only. |
hideWhenUpdating | hideWhenUpdating(): static | $this | Hide on update form only. |
showOnCreating | showOnCreating(): static | $this | Show on create form. Relationship fields whose persistence requires a saved parent stay off the create form: HasOne, HasMany, HasManyThrough, HasOneThrough, HasOneOfMany, MorphOne, MorphMany, MorphOneOfMany are detail-only by default, and BelongsToMany / MorphToMany never render on a create form (since v1.38.0, like Nova): no visibility call brings them there, since a pivot row needs the record's key. Attach once the record exists, or use Tag to pick related records while creating. |
showOnUpdating | showOnUpdating(): static | $this | Show on update form. |
Convenience Visibility Presets
| Method | Signature | Returns | Description |
|---|---|---|---|
onlyOnIndex | onlyOnIndex(): static | $this | Show only on index; hide everywhere else. |
onlyOnDetail | onlyOnDetail(): static | $this | Show only on detail; hide everywhere else. |
onlyOnForms | onlyOnForms(): static | $this | Show only on create/update forms; hide everywhere else. |
exceptOnForms | exceptOnForms(): static | $this | Show everywhere except forms. |
Context-Aware Visibility
| Method | Signature | Description |
|---|---|---|
isVisibleForContext | isVisibleForContext(string $context): bool | Check visibility for a given context. Contexts: index, detail, create, update, inline-create, preview. |
filterForContext | static filterForContext(array $fields, string $context): array | Filter an array of fields to only those visible in the given context. |
Resolution rules per context:
| Context | Resolves from |
|---|---|
index | showOnIndex |
detail | showOnDetail |
create / inline-create | showOnCreate ?? showOnForms |
update | showOnUpdate ?? showOnForms |
preview | showOnPreview ?? showOnDetail |
Field authorization: canSee() and canSeeForModel()
| Method | Signature | Description |
|---|---|---|
canSee | canSee(callable $callback): static | Show the field only when $callback(Request $request) returns true. |
canSeeWhen | canSeeWhen(string $ability, mixed ...$arguments): static | canSee() through a Gate ability of the current user. |
canSeeForModel | canSeeForModel(callable $callback): static | Show the field on a record only when $callback(Request $request, Model $model) returns true for that record. |
canSeeUsingPolicy | canSeeUsingPolicy(string $ability): static | canSeeForModel() through a Gate ability of the current user on the record. |
isAuthorizedToSee | isAuthorizedToSee(Request $request): bool | Resolve canSee(). |
isAuthorizedForModel | isAuthorizedForModel(Request $request, Model $model): bool | Resolve canSeeForModel() for a record. |
filterForModel | static filterForModel(array $fields, Request $request, Model $model): array | The fields of a list the user may see on a record (v1.38.0). |
sortableAttributes | static sortableAttributes(array $items, Request $request): array | The attributes a request may sort a list by: those of the sortable() fields the user may see, layout containers opened (v1.38.0). |
searchableFields | static searchableFields(array $items, Request $request): array | The fields a search matches its term on: the searchable() fields the user may see, layout containers opened (v1.38.0). |
hiddenAttributes | static hiddenAttributes(array $fields, array $visible): array | The attributes of the fields of a list a record hides, given the ones filterForModel() kept: a serialised record's _hidden (v1.38.0). |
filterLayoutFields | static filterLayoutFields(array $items, Closure $keep): array | A field list without the fields $keep rejects, its layout containers rebuilt with the fields they keep and left out when they keep none (v1.38.0). |
A field the user cannot see is hidden by the server, not only by the UI. canSee() hides it for the request, canSeeForModel() for one record, and both hold on every endpoint:
- Reads.
canSee()leaves the field out of every field list of the schema and of every value the API sends.canSeeForModel()leaves its value out of each record it hides the field for: the index, the detail, the values of the update form, the replicate form, the rows and the record of a relationship panel (and the responses of its inline create and update), the rows of a lens, the peek card, and for a pivot field the_pivotvalues of the attached records. The schema describes the resource, not a record, so each record lists under_hiddenthe 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 aHasOne/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_pivotvalues 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 aHasMany/HasOne/MorphMany/MorphOnepanel, and the attach and pivot update of aBelongsToMany/MorphToManyfor its pivot fields (the attach stores the field'sdefault()). - 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'sfieldsForCreate(a layout container left without fields goes with them) andfieldsForInlineCreate, and the inline-create schema, are decided on a new model. Grant on! $model->existsto 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, elsePivot/MorphPivot) whosepivotParentis 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'sdefault()). - Relationship fields. A
HasMany,HasOne,MorphMany,MorphOne,BelongsToManyorMorphToManyfield thatcanSeeForModel()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 thedependsOnsync answer for a hidden field exactly as for an attribute nothing declares: 404 for a picker and the Slug check, 422 for theSelectsearch 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 fieldcanSee()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 (itsdefaultSort()too), the rows of aHasMany,MorphMany,BelongsToManyandMorphToManypanel, 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. Afield:valuetoken naming a field the user cannot see is dropped like one naming an unknown field. What a resource declares itself is searched as declared: itssearchableRelations()paths, itssearchQuery()predicate and, with Scout, itstoSearchableArray(). - Layouts. The rules are the same whatever layout container holds the field:
Panel,Section, or directly aTab.
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
| Method | Signature | Returns | Description |
|---|---|---|---|
sortable | sortable(bool $value = true): static | $this | Allow sorting the index table by this field, for the users who can see it (see Field authorization). |
searchable | searchable(bool $value = true): static | $this | Match the searches (resource index, relationship panels, relation pickers, global search) on this field, for the users who can see it (see Field authorization). |
sortUsing | sortUsing(Closure(Builder $query, SortDirection $direction): void $callback): static | $this | Write the ordering by this field yourself, for an attribute that is not a plain column of the listed table. Implies sortable(). See Sorting by a computed key. |
isSortable | isSortable(): bool | bool | Check if sortable. |
isSearchable | isSearchable(): bool | bool | Check if searchable. |
Sorting by a computed key (sortUsing())
A key that is not a column of the listed table (a category's position, kept in another table) cannot be ordered by orderBy($attribute). sortUsing() hands the ordering to a closure, which receives the query and the requested SortDirection:
BelongsTo::make('Category', 'category') ->sortUsing(fn (Builder $query, SortDirection $direction) => $query->orderBy( Category::select('position')->whereColumn('categories.id', 'promotion_materials.category_id'), $direction->value, )),- It implies
sortable(). - It applies on every surface that sorts by the attribute: the index, a lens and the relationship panels, so a panel and the index agree, and in a multi-key
?sort=and indefaultSort(). - The closure only adds order clauses; it must not add
whereclauses (documented, not enforced). - A field the user cannot see (
canSee()) is not sortable through it either: the closure never runs.
Validation
| Method | Signature | Returns | Description |
|---|---|---|---|
rules | rules(array|Closure $rules): static | $this | Add validation rules that apply on every context (merged with existing). Accepts a closure for request-time resolution; closure replaces any prior static rule list (and vice versa). See Closure-aware setters. |
creationRules | creationRules(array $rules): static | $this | Rules that apply ONLY in the create context: POST /resources/{r}, the inline create of a HasMany / HasOne / MorphMany / MorphOne, and the attach of a BelongsToMany / MorphToMany (pivot fields). Layered on top of rules(). |
updateRules | updateRules(array $rules): static | $this | Rules that apply ONLY in the update context: PUT /resources/{r}/{id}, the inline update of a HasMany / HasOne / MorphMany / MorphOne, and the pivot update of a BelongsToMany / MorphToMany (pivot fields). Layered on top of rules(). |
buildRules | buildRules(?string $context = null): array | array | Build the final rule array. Pass 'create' or 'update' to layer the matching context rules. |
validationMessages | validationMessages(): array | array | Custom validation messages (e.g. for unique). |
Context-aware example — password is required on create but optional on update (the canonical pattern):
Password::make('password') ->rules(['min:8']) // applies on every context ->creationRules(['required']) // create only ->updateRules(['nullable']); // update onlyEvery 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,bothabove), which the hidden case (no value sent) satisfies. hide()differs fromhideFromForms():hideFromForms()takes the field off the form schema for good,hide()keeps it there soshow()can bring it back.
| Method | Signature | Description |
|---|---|---|
hide / show | hide(): static, show(): static | Hide the field on its form (not drawn, not submitted) or show it again. Serialized as hidden. |
dependsOn | dependsOn(array $fields, ?Closure $callback = null): static | Declare reactivity. The closure is optional; passing null lets layouts forward the watch list without running a callback (the Repeater uses this form for parent-attribute exposure). |
dependentFields | dependentFields(): list<string> | Read the watched attributes. |
isDependent | isDependent(): bool | True only when both $fields and a Closure are configured. The sync endpoint refuses non-reactive fields. |
syncDependent | syncDependent(array $formData, Request $request): static | Run the closure against the given payload. Mutates $this. Useful in tests. |
Wire format. The schema endpoint serializes dependsOn: { fields: ['attr1', 'attr2'] } (an object) for reactive fields and null for non-reactive ones. The frontend useDependsOnSync hook subscribes to the listed attributes, debounces 200ms, and POSTs the payload when any watched value changes — older requests are aborted via AbortController so the latest value always wins.
Endpoint. POST /api/resources/{r}/sync-field body: { field: string, formData: object, context?: 'create' | 'update' }. Response: a single field descriptor in the same shape as schema.fields[]. Authorization gates the same as create/update. The field is looked up on the form of context only, layout containers included: fieldsForUpdate() for update, fieldsForCreate() then the inline-create modal's fieldsForInlineCreate() for create (v1.38.0: a reactive field declared on the inline-create form alone used to answer 422). A field that is not on that form is 422 Unknown field.
Closure-aware setters
Most field setters accept either a static value or a closure that resolves at request time. Reach for the closure form whenever the result depends on the authenticated user, the locale, the active tenant, or anything else that lives on the Request.
The closure receives the active Request (or null when called outside an HTTP context, e.g. a queue worker) and must return the resolved value. Static values still work — the closure form is purely additive.
| Setter | Signature | Lazy reader |
|---|---|---|
readonly | readonly(bool|Closure $value = true): static | isReadonly(): bool |
required | required(bool|Closure $value = true): static | isRequired(): bool |
nullable | nullable(bool|Closure $value = true): static | isNullable(): bool |
default | default(mixed $value): static | getDefaultValue(): mixed |
placeholder | placeholder(string|Closure $text): static | getPlaceholder(): ?string |
help | help(string|Closure $text): static | getHelp(): ?string |
tooltip | tooltip(string|Closure|null $text): static | getTooltip(): ?string |
withLabel | withLabel(string|Closure $value): static | label(): string |
rules | rules(array|Closure $rules): static | buildRules(): array |
Select::options | options(iterable|Arrayable|string|Closure $options): static | getOptions(): list |
MultiSelect::options | options(iterable|Arrayable|string|Closure $options): static | getOptions(): list |
BooleanGroup::options | options(array|Closure $options): static | getOptions(): array |
Examples:
// Per-user defaultsText::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--acceptsprefillBelongsTo::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 policyText::make('email') ->readonly(fn ($request) => $request?->user()?->cannot('change-email')); // Required only for non-adminsText::make('reason') ->required(fn ($request) => $request?->user()?->cannot('skip-reason')); // Locale-aware label and placeholderText::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 roleText::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 closureIf you need both static and dynamic rules, return them together from a single closure:
->rules(fn ($request) => array_merge(['min:1'], $request?->user()?->isAdmin() ? [] : ['max:100']))Unique Validation
| Method | Signature | Returns | Description |
|---|---|---|---|
unique | unique(array $config, ?string $message = null): static | $this | Mark as unique. Config: [table] or [table, column]. |
setUniqueIgnoreId | setUniqueIgnoreId(int|string|null $id): void | void | Set ID to ignore for unique validation (used on updates). |
Email::make('email') ->unique(['users', 'email'], 'This email is already in use.')When the database index refuses the write (v2.9.0)
The rule runs before the write, so a unique index can still refuse it: two saves racing past the rule, or an index the rule does not mirror (a case-insensitive lower(email) index, a composite index). Martis answers that write with a 422, as the rule would, on every endpoint that saves a record: create, update, inline create, and a relationship panel's create, update, attach and pivot update. The error sits on each field of the form whose column the index covers, with the field's unique() message, or else Laravel's validation.unique line on its label ("The Email has already been taken."). The response's message is A record with this value already exists. Please use a unique value.
The columns come from the database's error message:
| Database | It reports | Martis reads |
|---|---|---|
| PostgreSQL | the key: Key (lower(email::text))=(…), Key (tenant_id, email)=(…) | the column names in the key (function names, casts and string literals skipped); the constraint name only when the message carries no key |
| SQLite | the columns (UNIQUE constraint failed: users.email), or the index name for an expression index | the columns, or the index name |
| MySQL, MariaDB | the index name only (for key 'users.users_email_unique'); PRIMARY for the primary key | the columns the index name contains, split on the table's columns, longest first |
An index name works when it contains the column, as Laravel's default {table}_{column}_unique does (users_email_lower_unique works too). It is split on every column of the table, so users_first_name_unique names first_name, never a written name. A name that contains none of the form's columns (uniq_domain), or an index on a column the form does not write, answers the same 422 without a field error, so the form shows the message only. Only the fields of the form are matched, so a function name (lower) or a column the form lacks never becomes an error key. A foreign key or NOT NULL failure answers a 500 with a message of its own. No answer ever carries SQL or the database's message.
Customization Hooks
The core hooks let you replace the default read / write / format behaviour of a field with arbitrary logic. Unlike the lazy setters in Closure-aware setters, these hooks do not have a static counterpart — they ARE the customization.
| Method | Signature | Returns | Description |
|---|---|---|---|
resolveUsing | resolveUsing(callable $callback): static | $this | Override value resolution. Callback: fn(mixed $value, Model $model, string $attribute, ?Request $request): mixed |
fillUsing | fillUsing(callable $callback): static | $this | Override model filling. Callback: fn(Model $model, mixed $value, string $attribute, ?Request $request): void |
displayUsing | displayUsing(callable|list<callable> $callback): static | $this | Override display formatting (applied after resolveUsing, does NOT affect form values). Single callable: fn(mixed $value, Model $model, string $attribute, ?Request $request): mixed. Pass an array of callables to compose a pipeline. |
computed | computed(?callable $callback = null): static | $this | Mark the field as computed: the raw value never comes from $model->getAttribute(). Callback: fn(Model $model, string $attribute, ?Request $request): mixed. Without a callback the raw value is null (pair with resolveUsing()). Hidden from forms; fill() is a no-op unless fillUsing() is set. See Computed fields. |
⭐ 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 → prefixText::make('amount')->displayUsing([ fn ($v) => (float) $v, fn ($v) => number_format($v, 2), fn ($v) => "R$ {$v}",]); // Pipeline + per-stage Request access:Text::make('balance')->displayUsing([ fn ($v) => (float) $v, fn ($v, $m, $attr, ?Request $r) => $r?->user()?->isAdmin() ? number_format($v, 2) : '*****', fn ($v) => "<strong>{$v}</strong>",]);If any entry in the array is not callable, displayUsing() throws InvalidArgumentException immediately at definition time — you get the failure when the resource boots, not deep inside a request.
A subsequent single-callable call replaces the entire pipeline. A subsequent array call replaces the prior single callable. The hook is monotonic: only the most recent definition is active.
Backward compatibility
| Old code | Still works? |
|---|---|
displayUsing(fn ($v) => ...) | ✅ |
displayUsing(fn ($v, $m) => ...) | ✅ |
displayUsing(fn ($v, $m, $attr) => ...) | ✅ |
resolveUsing(fn ($v, $m, $attr) => ...) | ✅ |
fillUsing(fn ($m, $v, $attr) => ...) | ✅ |
The 4th ?Request $request argument is purely additive. Existing 3-arg callbacks keep their semantics unchanged.
Computed fields
Every field has an attribute name, and by default resolve() reads that name from the model before anything else runs. That read is Eloquent's getAttribute(), which has two traps a resolveUsing() callback cannot avoid, because the read happens first:
- A name that matches a model method (
mode,status,type, …) is treated as a relationship:getAttribute('mode')callsmode()and throwsLogicException: …::mode must return a relationship instancewhen 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
MissingAttributeExceptionon persisted models once the host enablesModel::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 $valueBadge::make('mode', 'Mode') ->computed() ->resolveUsing(fn ($value, Channel $channel) => $channel->mode()->value);Callback signature: fn (Model $model, string $attribute, ?Request $request): mixed. As with the other hooks, trailing arguments are optional: fn (Channel $channel) => … works.
The computed value enters the normal pipeline, computed → resolveUsing → displayUsing, so formatting and per-request logic work exactly as for stored fields. Without a callback the raw value is null: resolveUsing() receives null, and with no resolveUsing() either the field resolves to null. A later computed() call replaces the callback.
What computed() changes besides the read:
| Concern | Behaviour |
|---|---|
| Forms | Hidden from create and update (hideFromForms()), like Nova's computed fields. Call showOnForms(), showOnCreating() or showOnUpdating() after computed() to show it again. |
fill() | No-op: there is nothing to write. A fillUsing() callback still runs, so a computed full_name can write first_name / last_name on save. readonly() keeps precedence. |
sortable() / searchable() / filters | Not overridden, and not supported: there is no column to sort or search by, and a Filter whose column is the computed attribute would query a column that does not exist. Leave them off. |
| Introspection | isComputed(): bool. Not part of toArray(); the SPA does not need it. |
Which fields honour computed(): every field whose value resolution goes through Field::resolve() (Text, Badge, Status, Number, Boolean, Date, Select, …) plus the ones with their own resolve() that read through the shared seam: KeyValue, MultiSelect, Sparkline, File, Image, Gravatar. Relationship fields read a foreign key or relation rather than the attribute, Icon has its own display/stored/computed modes, UiAvatar derives its initials from from() / the attribute directly, and Repeater has its own storage modes; computed() does not apply to them.
Custom field types that override resolve() must read the model through $this->resolveAttribute($model, $attribute) instead of $model->getAttribute(), otherwise computed() is silently ineffective for that type. A type that only needs to look at the stored value (for example to check it) should override inspectResolvedValue(Model $model, string $attribute, mixed $value): void instead: Field::resolve() calls it with the value it just read, before resolveUsing(), so the model is read once and an accessor or a computed() callback does not run twice per row.
Without computed(), the rule stands: a field's attribute must be a real attribute (column, cast, accessor or loaded relation) and must not share its name with a model method.
Component Override
| Method | Signature | Returns | Description |
|---|---|---|---|
component | component(string $key): static | $this | Override the React component used to render this field. |
getComponentKey | getComponentKey(): ?string | ?string | Get custom component key (null = use default). |
Metadata
| Method | Signature | Returns | Description |
|---|---|---|---|
withMeta | withMeta(array $meta): static | $this | Merge arbitrary key-value metadata into the field descriptor. |
Index Table Column Width
Controls how the field renders as a column in the index table. See resources.md for the Resource-level tableLayout() switch.
| Method | Signature | Description |
|---|---|---|
width | width(string $value): static | Fix the column width (e.g. "80px", "10rem"). |
minWidth | minWidth(string $value): static | Minimum width. Useful on title columns that would otherwise collapse. |
maxWidth | maxWidth(string $value): static | Maximum width. Pair with truncate() on URL / email columns. |
truncate | truncate(bool $value = true): static | Clip overflow with an ellipsis. Call with false to cancel a type default. |
textAlign | textAlign(string $align): static | Align the column header and cells: left, center or right (the Nova method). The sort icon stays beside the label, and a right-aligned column uses tabular figures so digits line up. Opt-in: without the call the column keeps today's alignment. Any other value throws InvalidArgumentException naming it. v2.23.0+. |
hideBelow | hideBelow(string $breakpoint): static | Hide the column (header and cells) on the index when the viewport is narrower than the breakpoint: sm (640px), md (768px), lg (1024px) or xl (1280px). A pure CSS rule on the viewport width, so hideBelow('md') hides the column below 768px. The field stays on the detail page and in forms. Any other value throws InvalidArgumentException naming it. v2.23.0+. |
textAlign and hideBelow apply to the resource index and lenses. The relationship tables on a detail page do not read the column metadata.
Type defaults (applied automatically unless overridden):
| Field | Default |
|---|---|
Id | width 80px |
Email, Url | maxWidth 280px + truncate |
Boolean, Status, Badge | width 120px |
Date, DateTime | width 140px |
Column matching titleAttribute() | minWidth 220px |
All defaults can be disabled globally via config('martis.index.column_defaults', false) (env MARTIS_INDEX_COLUMN_DEFAULTS). Explicit per-field calls still apply. See resources.md for details.
Serialization
| Method | Signature | Description |
|---|---|---|
toArray | toArray(): array | Serialize field to array for JSON API. Includes: attribute, label, type, nullable, readonly, required, sortable, searchable, showOnIndex, showOnDetail, showOnForms, rules, component, plus extraAttributes() and meta. |
Tooltips (Martis differential)
⭐ Martis-exclusive. In addition to help() (plain text under the input),
Martis adds label tooltips as a separate channel so
authors can surface contextual guidance without committing valuable real estate
to permanent inline text.
Why it matters
help()is always visible. It costs vertical space on every form render, for every user, regardless of how long they've been using the resource.- A tooltip is opt-in: the hint shows only when the user hovers the
discreet
(?)icon next to the label. Experienced users never see it; new users have it one hover away. - HTML support means you can pack rich, multi-line guidance (lists, bold, line breaks) into a single call without bloating the form.
API
Text::make('name', 'Full name') ->tooltip('<strong>Full legal name</strong>.<br />Examples:<br />• John Smith<br />• Ana Pereira<br /><br /><em>Avoid abbreviations.</em>');| Signature | Notes |
|---|---|
tooltip(?string $text): static | Sets the tooltip. Pass null to clear. |
getTooltip(): ?string | Returns the current tooltip text (or null). |
The tooltip string ships to the frontend in the serialized field under the key
tooltip and is rendered by the global MartisTooltip component behind the
(?) label icon on any form renderer (Panel, Section, TabGroup, ResourceCreate,
ResourceUpdate) and on detail labels rendered inside Sections/TabGroups.
HTML support
The label renderer registers its (?) icon as a trigger that may show HTML
(htmlTooltip(), see Tooltip Standard),
which makes the global MartisTooltip provider render the text as HTML; any
other data-pr-tooltip trigger keeps the default plain-text escape, whatever
attributes it carries (the metric help() tooltip and an extension's own
triggers register the same way).
Allowed markup: any inline HTML (<br />, <strong>, <em>, <ul>/<li>,
<code>, <a>). The markup is sanitised before it is shown (scripts, event
handlers, unsafe URLs, data-* attributes, the style attribute and element,
id, name, forms and form controls are removed), but the author is
still responsible for producing it and never puts user or record data in it; prefer
localised strings from __() / i18n dictionaries to keep content reviewable.
When to use tooltip() vs help()
| Situation | Use |
|---|---|
| Short, essential instruction that every user should read (e.g. "Must be unique") | help() |
| Long-form, rarely-needed context (examples, edge cases, links to docs) | tooltip() |
| Multi-line rich guidance (bullet lists, bold headings) | tooltip() |
| Validation hint that depends on input state | help() |
Both can coexist on the same field: ->help('Must be unique')->tooltip('<strong>Uniqueness rules</strong><br />...').
Frontend behaviour
- The
(?)icon uses the muted text colour so it reads as a quiet affordance. - Hover delay is 500 ms — long enough that a cursor skimming the form doesn't flash tooltips, short enough that intentional hover feels responsive.
- Both channels wrap. The bubble is shrink-to-fit up to 360 px (never wider
than the viewport minus 16 px): a short label stays on one line, a sentence
wraps inside the bubble, and a long unbroken token (a URL, an id) breaks
inside it (
overflow-wrap: anywhere) instead of running past its edge. Plain text renders at 11 px with a tight padding; HTML content at 12 px with a 220 px minimum width so paragraphs read as prose. Since v1.38.0; before, a plain tooltip was pinned to one line in a 300 px box and a long one spilled out of the bubble. - Plain text stays plain whatever its length: a sentence in
data-pr-tooltipwraps without the HTML opt-in, and markup in it renders literally. UsehtmlTooltip()only for content that needs markup. - Position respects the trigger's
data-pr-position(defaults totop). The bubble is measured first, flips to the opposite side when the requested one has no room for it (aleft/rightbubble 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:
- A
Fieldrepresents a column or value. A pure tooltip has nothing to read, write, validate, or serialise — it breaks the contract. - 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
colSpanand layout placement. - 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 attributeDefault overrides:
readonly = trueshowOnForms = falsesortable = true
Specific methods: None (all from base).
Text
Type identifier: text
Extends: Field
File: src/Fields/Text.php
Single-line text input. Renders as <input type="text">.
Text::make('title') ->sortable() ->searchable() ->required() ->placeholder('Enter title...')Specific methods: None (all from base).
Textarea
Type identifier: textarea
Extends: Field
File: src/Fields/Textarea.php
Multi-line text area. Renders as <textarea>. It opens at its row count and grows to fit a longer text, and the form or the modal body scrolls.
Textarea::make('body', 'Content') ->rows(10) ->nullable() ->hideFromIndex()| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
rows | rows(int $rows): static | $this | Set the row count the field opens at. | 5 |
getRows | getRows(): int | int | Get row count. | — |
Extra attributes: rows
Number
Type identifier: number
Extends: Field
File: src/Fields/Number.php
Numeric input. Renders as <input type="number">.
Number::make('price', 'Price') ->min(0) ->max(9999) ->step(0.01) ->integer() ->nullable()| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
min | min(int|float $min): static | $this | Set minimum value (adds min:N validation rule). | null |
max | max(int|float $max): static | $this | Set maximum value (adds max:N validation rule). | null |
step | step(int|float $step): static | $this | Set stepping interval. | null |
integer | integer(): static | $this | Enforce integer validation. | — |
Extra attributes: min, max, step (only non-null values)
Input: the form input takes the decimal separator of the user's Martis locale (v2.1.0). The index and detail pages show the value as stored.
Boolean
Type identifier: boolean
Extends: Field
File: src/Fields/Boolean.php
Toggle/checkbox field. Casts values to strict boolean.
Boolean::make('is_active', 'Active') ->trueLabel('Enabled') ->falseLabel('Disabled') ->sortable()| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
trueLabel | trueLabel(string $label): static | $this | Label for true value on index/detail. | __('martis::messages.yes') |
falseLabel | falseLabel(string $label): static | $this | Label for false value on index/detail. | __('martis::messages.no') |
Overrides: resolve() casts to (bool), fill() casts incoming to (bool).
Extra attributes: trueLabel, falseLabel
BooleanGroup
Type identifier: boolean_group
Extends: Field
File: src/Fields/BooleanGroup.php
Map of named boolean flags stored as JSON. Ideal for permission grids, feature gates, notification preferences.
BooleanGroup::make('permissions') ->options([ 'clients.view' => 'View clients', 'clients.edit' => 'Edit clients', 'billing.view' => 'View billing', ]) ->grouped([ 'Clients' => ['clients.view', 'clients.edit'], 'Billing' => ['billing.view'], ]) ->requireAny();| Method | Description |
|---|---|
options(array|Closure) | Flag key → label map. Accepts a closure that resolves at render time. See Closure-aware setters. |
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 withminChecked(int)directly, or userequireAny()(always1).
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 storesnullwhen no hidden flag is left to keep; - a
fillUsing()callback receives the offered flags only (the same projection), and a readonly orcomputed()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 paletteResolution priority (per row):
| # | Condition | Output |
|---|---|---|
| 1 | Stored file exists | <img src={uploaded_url}> |
| 2 | Developer set fallback($url | Closure) | <img src={fallback}> |
| 3 | Seed has initials | Inline coloured initials — no external request |
| 4 | Seed exists but produces no initials | Muted user-glyph chip (.martis-avatar-fallback) |
| 5 | Nothing at all | em-dash placeholder |
| Method | Description |
|---|---|
shape(AvatarShape) | Typed enum: Circle (default), Rounded, Squared |
circle() / rounded() / squared() | Convenience shortcuts |
fallback($url | Closure) ⭐ | Override the default inline initials with a custom URL (static or per-row) — opt-in, usually unnecessary |
initialsFrom(string) ⭐ | Seed attribute for initials + palette (default: name) |
colorFrom(string) ⭐ | Pull the initials background from a model attribute (e.g. brand_color) |
initials(Closure) ⭐ | Custom initials computation. Closure receives ($seed, $model) |
Initials payload. When the field falls back to initials, its value carries isInitialsFallback: true and:
| Key | Value |
|---|---|
initials | The letters: the first letter of the seed's first and last words, or your initials() closure's result. |
palette | The slot (1 to 16) of the theme's --martis-avatar-N tokens the circle is painted with, or null when colorFrom() gave the colour. |
color | The literal colour: the colorFrom() value, or the slot's colour in the built-in theme. |
seed | The seed attribute's value. |
A custom component that reads the payload paints var(--martis-avatar-{palette}) when palette is set, so it follows the theme like the built-in circle, and color otherwise.
⭐ Martis differentials:
- Zero-config inline initials fallback — no external service, no extra closures, works out of the box.
- Deterministic 16-hue palette declared as
--martis-avatar-1..16tokens, 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-fallbackinstead of a bare em-dash. - Per-row Closure-aware
fallback()when you do want a custom URL. - Typed
AvatarShapeenum instead of a booleanrounded(). - Initials and palette shared with
UiAvatar, the Topbar and the profile page throughMartis\Support\Initials(the fields reach it via theResolvesInitialsPayloadtrait).
UiAvatar
Type identifier: ui_avatar
Extends: Field
File: src/Fields/UiAvatar.php
Display-only initials pill. Differs from Avatar in one key way: it never takes an upload — the value is computed entirely from the model.
When to pick UiAvatar over Avatar:
| Your resource… | Use |
|---|---|
| has a photo column and you want users to upload | Avatar (initials inline when empty come for free) |
| has no photo column at all and never will | UiAvatar |
| is read-only / system-generated and an upload input would be noise | UiAvatar |
UiAvatar::make('avatar_initials') ->from('name') ->colorFrom('brand_color') ->circle();| Method | Description |
|---|---|
from(string) | Attribute used as the seed (default: field's own attribute) |
shape(AvatarShape) / circle() / rounded() / squared() | Match the visual style of Avatar |
colorFrom(string) ⭐ | Pull background colour from a model attribute (brand colour) |
initials(Closure) ⭐ | Custom initials computation. Closure receives ($seed, $model) |
The value has the same initials, palette, color and seed keys as the Avatar initials payload, plus shape.
⭐ Martis differentials: deterministic 16-slot palette from seed hash (same name → same colour, zero DB; a theme recolours it through the --martis-avatar-N tokens), colorFrom('attribute') override, custom-initials closure, decoupled seed via from(). Computed with the record, painted inline: no external service call.
Audio
Type identifier: audio
Extends: File
File: src/Fields/Audio.php
File upload specialised for audio clips. Renders a fully custom on-brand player (accent play/pause button, waveform OR progress track, mono current/total timestamps, optional download affordance) and a drag-and-drop dropzone empty state. The native <audio controls> element is intentionally bypassed so the look stays consistent under both light and dark themes.
Audio::make('intro_audio_path') ->disk('public') ->storagePath('team-audio') ->maxSize(10240) ->downloadable(false);| Method | Description |
|---|---|
waveform(bool) ⭐ | Toggle the canvas waveform (default true). When false the player falls back to a thin progress track. |
downloadable(bool) | Toggle the download icon next to the player. |
acceptedTypes(array) | Override the default mp3/wav/ogg/m4a/flac/aac allow-list. |
Empty state. When no file is attached, the input renders a dashed dropzone with a Phosphor MusicNote icon, the audio_empty_title and audio_empty_hint copy, and a "Choose file" CTA. Drag-and-drop is supported.
i18n keys (in messages.php, all three locales):
audio_empty_title, audio_empty_hint, audio_browse, audio_replace, audio_remove, audio_play, audio_pause, audio_download.
⭐ Martis differentials: client-side canvas waveform via Web Audio API (no server rendering), custom player chrome that follows the design tokens, drag-and-drop dropzone, downloadable(bool) toggle.
Select
Type identifier: select
Extends: Field
File: src/Fields/Select.php
Version note. Martis v2.0 and later read
options()as[value => label], Nova's order; Martis v1.x read[label => value]. On v1.x, flip the arrays in the examples below, or upgrade: see Upgrading.
Dropdown select with predefined options.
Select::make('status') ->options([ 'draft' => 'Draft', 'published' => 'Published', 'archived' => 'Archived', ]) ->nullable()| Method | Signature | Returns | Description |
|---|---|---|---|
options | options(iterable|Arrayable|string|Closure $options): static | $this | Set options in Nova's order, [value => label]: the key is stored, the value is shown. Also takes a list (stores 0, 1, 2…, as in Nova), grouped options ([value => ['label' => ..., 'group' => ...]]), a Collection or any other iterable (as in Nova), an enum class (Martis extension), or a closure that resolves at render time (perfect for DB-backed lists). See Option order and Closure-aware setters. |
withoutOptionOrderWarnings | withoutOptionOrderWarnings(): static | $this | Silence the stored-label and list-shift warnings below, and the searchOptionsUsing() list warning, for this field (Martis extension). Use it when a stored value that looks like stale v1.x data is actually correct, for example a Nova rating that stores 0..4 on purpose and shows "1".."5". |
displayUsingLabels | displayUsingLabels(): static | $this | Render the option label on index and detail (default behaviour). Symmetric with MultiSelect::displayUsingLabels() for code that handles both fields generically. |
displayUsingValues | displayUsingValues(): static | $this | Render the raw stored value on index and detail. Useful when the value is itself meaningful (ISO codes, slugs) and the label is just a humanised alias. |
isDisplayingLabels | isDisplayingLabels(): bool | bool | Whether the field currently renders labels (true) or raw values (false). |
getOptions | getOptions(): array | array | Get normalized options [{label, value, group?}] (resolves the closure if one was set). |
searchableOptions | searchableOptions(bool $value = true): static | $this | Render a search box above the option list so the user can narrow it by label or value (v1.37.0). Not the same as searchable(), which makes the column part of the resource search. |
hasSearchableOptions | hasSearchableOptions(): bool | bool | Whether the option list renders with a search box. |
allowCustomValues | allowCustomValues(bool $value = true): static | $this | Accept a typed value that is not one of the options (v1.37.0). The stored value may then fall outside getOptions(); index and detail render it raw. Validation against the list stays yours (Rule::in, or validateAgainstOptions() when the list must be closed after all). |
validateAgainstOptions | validateAgainstOptions(bool $value = true): static | $this | Reject, on the server, a value that is not one of the options (v2.4.0, Martis extension). Off by default: a Select has never validated against its options. See Validating against the options. |
validatesAgainstOptions | validatesAgainstOptions(): bool | bool | Whether the field rejects a value that is not one of its options. |
allowsCustomValues | allowsCustomValues(): bool | bool | Whether the control accepts values outside the option list. |
searchOptionsUsing | searchOptionsUsing(Closure $resolver): static | $this | Search the options on the server as the user types (v1.37.0). The closure receives (string $term, ?Request $request) and returns the same shapes options() accepts. Implies searchableOptions(). Resource forms and Tools implementing ProvidesFields only; see below. |
hasRemoteOptionsSearch | hasRemoteOptionsSearch(): bool | bool | Whether a server-side resolver is registered. |
searchOptions | searchOptions(string $term, ?Request $request = null): array | array | Run the server-side resolver and normalise the result to [{label, value, group?}]. Empty list without a resolver. |
Extra attributes: options, displayLabels, searchableOptions, allowCustomValues, remoteOptionsSearch
// Static options: [value => label]Select::make('status')->options(['draft' => 'Draft', 'published' => 'Published']); // Translated labels: the stored value stays fixed while the label is translatedSelect::make('plan')->options([ 'free' => __('plans.free'), 'pro' => __('plans.pro'),]); // Database-backed options resolved at render time: stores the idSelect::make('owner_id')->options(fn () => User::query()->pluck('name', 'id')->all()); // Grouped options, in Nova's formatSelect::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 nameSelect::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'])stores0and1, as in Nova. When each value is its own label, passarray_combine($values, $values). A list of numbers is the trap:options([1, 2, 3])stores0,1and2, sooptions(range(1, 12))for months stores 0 for January. - Grouped options map a value to
['label' => ..., 'group' => ...]. Any other array value throws anInvalidArgumentExceptionthat names the field and the option (the pre-v2['Group' => ['Label' => 'value']]format included). TheSelectdropdown shows each group under its heading, in the order the groups first appear, with ungrouped options on top; theMultiSelectlist 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 builtSelect::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 withmartis::validation.not_in_options(The selected Status is invalid., translated inen,pt_PTandpt_BR, with the field's label) as a422on 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
7accepts7and'7', not'07','7.0',7.0,trueor an array. Grouped options count by their value; a list (['Small', 'Large']) accepts0and1; an enum class accepts its case values. - An empty value is left to
required()andnullable(), as for every other field. On createrequired()refuses it. On update every field'srequiredis dropped (a form that leaves a field alone, aPasswordfor 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 ingetOptions()and would be rejected: validate that field with a rule of your own. WithallowCustomValues()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 scaffoldedInviteUserandBulkAssignRoleactions 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 carrySelect::make('model')->options(['gpt-4o' => 'gpt-4o'])->searchableOptions()->allowCustomValues(); // Very long list: keep the first page in options(), search the rest on the serverSelect::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 serialisedoptions. Coming from Nova: Nova'sSelect::searchable()is this method. In Martis,searchable()on any field (includingSelect) means "the column takes part in the resource search", and it is serialised assearchable; 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 aTextfield, and is stored as-is;SelectFieldDisplayrenders a value with no matching option as the raw string. The field adds noRule::invalidation, 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 initialoptions()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, likesync-field) andGET /api/tools/{uriKey}/fields/{attribute}/optionsfor a Tool implementingProvidesFieldswhose form passestoolKey(see Tool fields). A select in aRepeaterrow 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 overoptions(). 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 fromoptions()only, so a value that only the search returns shows raw there, and a''shows the dash: list such an option inoptions()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(), orfieldsForCreate()and then the inline-create modal'sfieldsForInlineCreate()(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 compactmartis-filter-dropdownclass (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.remoteOptionsSearchonly does something when the form has aresourceKeyor atoolKey.
// FieldDefinition passed to runtime.FieldInput{ type: 'select', attribute: 'status', label: 'Status', variant: 'filter', className: 'w-48', options: [...] }These are frontend-only rendering hints; they have no effect on a plain Select field inside a normal Resource form.
GuardSelect
Type identifier: select (inherits)
Extends: Select
File: src/Fields/GuardSelect.php
Dropdown of the auth guards configured in the host app's config/auth.php. Designed for guard_name columns on Spatie Permission / Role tables — most installs have a single guard (web), but the field gives the developer a confident UI to pick from instead of typing free text. Shipped in v1.8.0.
use Martis\Fields\GuardSelect;use Martis\Fields\Slug; public function fields(Request $request): array{ return [ Id::make()->sortable(), Slug::make('name') ->separator('.') ->reserved(['*']) ->help(__('martis::permissions.name_help')) ->required(), GuardSelect::make('guard_name') ->help(__('martis::permissions.guard_help')) ->required(), ];}| Method | Signature | Returns | Description |
|---|---|---|---|
make | make(string $attribute, ?string $label = null): static | static | Factory. Auto-wires the guard-list resolver, pulls the default value from config('auth.defaults.guard'), marks the field required(), and validates submitted values against config('auth.guards') keys via Rule::in(...). |
only | only(array $guardNames): static | $this | Restrict the dropdown to a subset of the configured guards. Useful when a Resource only makes sense against one specific guard (api permissions vs web permissions managed in separate sections of the admin). Empty strings are filtered out. |
The label and value are always identical — guard names are tokens (web, api, sanctum) and translating them would be misleading. Server-side validation rejects any value not declared in config/auth.guards, which prevents Spatie Permission from later crashing on Role::users() (morphedByMany cannot resolve the User model for an unconfigured guard).
The list is fetched lazily at schema-render time so config() is read after the host app has finished booting. The catalog comes from Martis\Auth\GuardCatalog, which exposes available(): list<string> and default(): string for advanced uses.
MultiSelect
Type identifier: multi_select
Extends: Field
File: src/Fields/MultiSelect.php
Version note. Martis v2.0 and later read
options()as[value => label], Nova's order; Martis v1.x read[label => value]. On v1.x, flip the arrays in the examples below, or upgrade: see Upgrading.
Multi-value select with chips/tags UI. Stores as JSON array.
MultiSelect::make('technologies') ->options([ 'php' => ['label' => 'PHP', 'group' => 'Backend'], 'python' => ['label' => 'Python', 'group' => 'Backend'], 'react' => ['label' => 'React', 'group' => 'Frontend'], 'vue' => ['label' => 'Vue', 'group' => 'Frontend'], ]) ->displayUsingLabels() ->required()| Method | Signature | Returns | Description |
|---|---|---|---|
options | options(iterable|Arrayable|string|Closure $options): static | $this | Set options in Nova's order, [value => label], with the same forms as Select::options(): a list (0, 1, 2…), grouped options ([value => ['label' => ..., 'group' => ...]]), an enum class (Martis extension), or a closure that resolves at render time. See Option order and Closure-aware setters. |
withoutOptionOrderWarnings | withoutOptionOrderWarnings(): static | $this | Silence the stored-label and list-shift warnings (and, on Select, the searchOptionsUsing() list warning) for this field (Martis extension). See the same row on Select. |
displayUsingLabels | displayUsingLabels(): static | $this | Show labels instead of raw values on index/detail. |
getOptions | getOptions(): array | array | Get normalized options [{label, value, group?}] (resolves the closure if one was set). |
isDisplayingLabels | isDisplayingLabels(): bool | bool | Check if displaying labels. |
validateAgainstOptions | validateAgainstOptions(bool $value = true): static | $this | Reject, on the server, a selection that holds a value outside the options (v2.4.0, Martis extension). Off by default: like Select, a MultiSelect never validated against its options. Every selected value must be an option, matched by value as a string or an integer; an empty selection is left to required() and nullable(). See Validating against the options. |
validatesAgainstOptions | validatesAgainstOptions(): bool | bool | Whether the field rejects a selection that holds a value outside its options. |
Storage format: JSON array, e.g. ["php","react"]
Overrides: resolve() decodes JSON/array to list; fill() writes the list, JSON-encoded unless the attribute carries an array / json / class cast that serialises it itself (see Structured values and Eloquent casts).
Extra attributes: options, displayLabels
Date
Type identifier: date
Extends: Field
File: src/Fields/Date.php
Date picker (date-only by default).
Date::make('published_at', 'Published') ->withTime() // enable date+time mode ->format('d/m/Y') // custom display format ->nullable() ->sortable()| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
withTime | withTime(bool $value = true): static | $this | Enable date+time mode (changes format to Y-m-d H:i:s). | false |
format | format(string $format): static | $this | Customize display format. | Y-m-d |
Overrides: resolve() normalizes Carbon/DateTime to formatted string.
Extra attributes: withTime, displayFormat
Display: the index and detail pages show the date in the user's Martis locale, not the browser's (v2.1.0): 27/09/2026 for pt_PT, 9/27/2026 for en_US. A value format() turned into another string is shown as sent.
DateTime
Type identifier: datetime
Extends: Date
File: src/Fields/DateTime.php
Date + time picker. Extends Date; inherits all Date methods.
DateTime::make('created_at', 'Created') ->hideFromForms() ->sortable()Specific methods: None (inherits withTime(), format() from Date).
Display: the date and time in the user's Martis locale (v2.1.0).
Input: a calendar with a 24-hour time picker. The hour and minute can be typed (digits, then the arrow keys or the up/down buttons to step; values wrap around). Before v2.16.4 they could only be changed with the buttons.
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 = falseshowOnDetail = false
Overrides:
resolve()always returnsnull(never expose password hashes).fill()hashes withHash::make(). Skips empty/null values (no update if blank). AfillUsing()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'sPasswordfield 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 withPasswordConfirmationto 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. Addsmin:N.requireUppercase(bool $value = true): static— addsregex:/\p{Lu}/u.requireLowercase(bool $value = true): static— addsregex:/\p{Ll}/u.requireNumber(bool $value = true): static— addsregex:/\pN/u.requireSymbol(bool $value = true): static— addsregex:/\p{Z}|\p{S}|\p{P}/u.defaultRules(bool $value = true): static: validates with the app'sPassword::defaults()and shows its requirements in the checklist (v2.3.0), as Nova's generated User resource validates withPassword::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 = falseshowOnDetail = falsefill()is a no-op (companion fields never hydrate the model).resolve()always returnsnull.
Specific methods:
confirms(string $attribute): static— Name the paired password attribute (default:'password'). Matches Laravel'sconfirmedrule 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
Passwordfield haswithStrengthMeter(), 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 emitsslug_reservederror; the/slug-checkendpoint 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'sscopes()andindexQuery()instead of the whole table. Enable it when uniqueness is per tenant or per owner (a composite unique index such astenant_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 forbadgeVariant('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. ImpliesbadgeVariant('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
suggestionis non-null. The check uses the Slug declared on the form it comes from:fieldsForUpdate()whenidnames a record the user may update (authorizedToUpdate()), otherwisefieldsForCreate()and thenfieldsForInlineCreate(), thenfields(). A Slug declared on one form only is found, and that declaration'sseparator()andreserved()apply (v1.38.0: the check readfields()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; anidthe user may not update is answered like one that names no record (v1.38.0: any recordidnamed 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
viewAnyalone:authorizedToUpdate()on the recordidnames, otherwiseauthorizedToCreate()(a user who may only list the resource gets 403, for anyid, so it cannot be used to test which slugs exist; before v2.4.0viewAnywas 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,-3suggestions) then runs through the resource'sscopes()andindexQuery()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().timeZoneand 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 pickerIcon::make('industry_icon') ->stored() ->palette(['rocket', 'buildings', 'briefcase', 'globe']) ->colorFrom('brand_color') ->size(20) // Mode C — computed from the modelIcon::make('state')->icon(fn ($model) => $model->is_active ? 'check' : 'x')Factory:
Icon::make(string $attribute, ?string $fixedIcon = null, ?string $label = null)— when$fixedIconis 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)
- semantic tokens:
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 returnsstringor['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 respectsreadonly()(a closure is evaluated per request, soreadonly(fn ($request) => ! $request->user()->isAdmin())locks the write too, not only the input) andfillUsing(), as every field does.- Index rendering respects
size()— put a small Icon at the start offieldsForIndex()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 + linesStack::make('identity', 'Identity', [Line::make('name')]); // attribute + label + linesLine API — style variants:
| Method | Class | When to use |
|---|---|---|
asHeading() | .martis-line-heading | primary label (bold, slightly larger) |
asBase() | .martis-line-base | default body copy |
asSmall() | .martis-line-small | compact secondary text |
asMuted() | .martis-line-muted | de-emphasised supporting text |
asCode() ⭐ | .martis-line-code | monospace pill for slugs, IDs, tokens |
subtitleFrom($attrOrClosure) ⭐ | — | emit a muted second line pulled from another attribute, without declaring a second Line |
Stack API:
| Method | Description |
|---|---|
Stack::make($attribute, $lines) | Create a Stack from an array of Line instances |
Stack::make($attribute, $label, $lines) | Same, with explicit label |
divider(bool $enabled = true) ⭐ | Insert a thin separator between Lines |
getLines(): array<Line> | Inspect configured lines |
hasDivider(): bool | Introspection helper |
⭐ Martis differentials:
- Works on the index. Martis renders
Stackas an index-table cell, perfect for identity columns. ->asCode()variant — monospace styling for slugs, hashes, tokens.Line::subtitleFrom('attribute' | Closure)— one-line sugar to emit a muted secondary row without declaring a secondLine. Accepts a Closure receiving the model for derived subtitles.Stack::divider()— thin--martis-borderseparator between Lines; great for metadata listings.
Payload shape (per row)
The Stack emits { __martisStack: true, entries: [{ text, variant, subtitle }], divider }. The frontend StackField component detects the wrapper and renders each entry with the appropriate .martis-line-* class. Custom themes restyle every Line in the package by overriding the CSS variables used inside .martis-line-heading/base/small/muted/code.
Url
Type identifier: url
Extends: Field
File: src/Fields/Url.php
URL field with clickable link on index/detail and url validation. A stored value is a link only when the browser itself reads it as a web URL, a path, mailto: or tel: (v2.4.0: the same check the sidebar uses, so java<TAB>script: and other padded schemes are not links); anything else is shown as plain text.
Url::make('website') ->displayText('Visit Website') ->nullable()| Method | Signature | Returns | Description |
|---|---|---|---|
displayText | displayText(string $text): static | $this | Static display text for the link. For dynamic text, use displayUsing(). |
getDisplayText | getDisplayText(): ?string | ?string | Get display text. |
Overrides: buildRules() appends 'url' to validation rules.
Extra attributes: displayText
Color
Type identifier: color
Extends: Field
File: src/Fields/Color.php
HTML5 color picker. Stores hex string (e.g. #ff5733).
Color::make('brand_color', 'Brand Color')Specific methods: None (all from base). Extra attributes: None.
Country
Type identifier: country
Extends: Field
File: src/Fields/Country.php
ISO 3166-1 alpha-2 country picker. Stores 2-letter code (e.g. US, BR).
Country::make('country') ->withFlags() // show emoji flags ->searchable()| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
withFlags | withFlags(): static | $this | Enable flag emoji display. Martis extension. | false |
withoutFlags | withoutFlags(): static | $this | Disable flag emoji display. | — |
hasFlags | hasFlags(): bool | bool | Check if flags enabled. | — |
countryList | static countryList(): array | array | Full ISO 3166-1 country list [{label, value, flag}]. | — |
resolveCountryName | static resolveCountryName(string $code): ?string | ?string | Get country name from 2-letter code. | — |
resolveCountryFlag | static resolveCountryFlag(string $code): ?string | ?string | Get flag emoji from 2-letter code. | — |
Extra attributes: countries, showFlags
Currency
Type identifier: currency
Extends: Number
File: src/Fields/Currency.php
Monetary value input with currency formatting. Inherits min(), max(), step(), integer() from Number.
use Martis\Enums\CurrencyCode;use Martis\Enums\CurrencyDisplayMode; Currency::make('price') ->currency(CurrencyCode::EUR) ->asMinorUnits() // stored as cents ->displayMode(CurrencyDisplayMode::BadgeText) ->badgeColor('green')| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
currency | currency(CurrencyCode $code): static | $this | Set ISO 4217 currency code (typed enum). Auto-sets step. | CurrencyCode::USD |
locale | locale(string $locale): static | $this | Fixes the display and input locale of this field (e.g. de_DE), whatever the user's Martis locale. | the user's Martis locale |
asMinorUnits | asMinorUnits(): static | $this | Treat stored value as minor units (cents). | false |
asMajorUnits | asMajorUnits(): static | $this | Treat stored value as major units (dollars). | — |
displayMode | displayMode(CurrencyDisplayMode $mode): static | $this | Set display mode (typed enum: Text, Badge, BadgeText). Martis extension. | CurrencyDisplayMode::Text |
showBadge | showBadge(): static | $this | Shortcut for displayMode(CurrencyDisplayMode::Badge). | — |
showText | showText(): static | $this | Shortcut for displayMode(CurrencyDisplayMode::Text). | — |
showBadgeText | showBadgeText(): static | $this | Shortcut for displayMode(CurrencyDisplayMode::BadgeText). | — |
badgeColor | badgeColor(string $color): static | $this | Set badge color. Martis extension. | null |
getCurrencyCode | getCurrencyCode(): CurrencyCode | CurrencyCode | Get the currency enum case. | — |
getLocale | getLocale(): string | string | Get effective locale. | — |
isMinorUnits | isMinorUnits(): bool | bool | Check if using minor units. | — |
getDisplayMode | getDisplayMode(): CurrencyDisplayMode | CurrencyDisplayMode | Get display mode enum. | — |
getBadgeColor | getBadgeColor(): ?string | ?string | Get badge color. | — |
getCurrencyInfo | getCurrencyInfo(): array | array | Get {symbol, name, decimals} for current currency. | — |
Supported currencies: USD, EUR, GBP, BRL, JPY, CNY, CAD, AUD, CHF, INR, MXN, KRW, SEK, NOK, DKK, PLN, THB, ZAR, TRY, RUB, NZD, SGD, HKD, CLP, ARS, COP, PEN
Extra attributes: currencyCode, currencySymbol, currencyName, currencyDecimals, locale, minorUnits, displayMode, badgeColor + Number extras (min, max, step)
Display and input: the amount uses the separators of the user's Martis locale, unless locale() sets one, on the index, the detail page and the form input (v2.1.0). The locale extra attribute is present only when locale() was called.
BelongsTo
Type identifier: belongs_to
Extends: Field
File: src/Fields/BelongsTo.php
Many-to-one relationship field. Stores foreign key, displays related model title.
Which title (v2.17.0). Without titleAttribute(), the field shows the related resource's title(), as Nova does and as its own picker already did: a ContactResource whose titleAttribute() is label (or that overrides title()) labels the cell, the detail page and the edit form the way it labels the picker row. Before v2.17.0 the field read name and ignored the related resource, so a model without a name column showed #4. A related resource left on the base id default keeps name, as before, and an explicit titleAttribute() / displayColumn() always wins. Tag, BelongsToMany and MorphToMany follow the same rule.
// Recommended: pass FK column (auto-detects relationship via _id suffix)BelongsTo::make('user_id', 'Author') ->relatedResource('users') ->titleAttribute('name') ->sortable() // Alternative: pass relationship nameBelongsTo::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 modaluse Martis\Enums\ModalSize; BelongsTo::make('category_id', 'Category') ->relatedResource('categories') ->showCreateRelationButton() ->modalSize(ModalSize::Large)| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
titleAttribute | titleAttribute(string $attribute): static | $this | Attribute on related model for display label. An explicit one always wins. | the related resource's title() when it declares one (a titleAttribute() other than id, or its own title()), else 'name' (v2.17.0) |
displayColumn | displayColumn(string $column): static | $this | Alias for titleAttribute(). Sets which column appears in index/table cells. | as titleAttribute |
foreignKey | foreignKey(string $key): static | $this | Override FK column name. | {relationship}_id |
relatedResource | relatedResource(string $uriKey): static | $this | URI key of related resource for dropdown API, and the resource a write is checked against. A key no registered resource has throws (naming the field and the key) when a value is written. Without it the write is checked against the resource registered for the relationship's model, and refused (422) when that names no single resource. See Relationships → Writes follow the pickers. | null |
placeholder | placeholder(string|\Closure $text): static | $this | Custom placeholder shown when no value is selected. Closure receives (?Request $r) for per-request resolution. | translated 'Select {field}...' |
relationSearchable | relationSearchable(bool $value = true): static | $this | Enable/disable text search in dropdown. Without it the dropdown has no search box and lists up to 100 records, or the related resource's $relatableSearchResults (v1.38.0+; before, the flag was ignored and the search box always showed). | true |
relatableQueryUsing | relatableQueryUsing(\Closure $closure): static | $this | Per-field constraint on the picker query. Closure receives (Request $request, Builder $query): narrow the builder in place or return one (a returned Builder replaces the query, any other return value is ignored). Runs after the resource's static relatableQuery(). | null |
displayAsLink | displayAsLink(bool $value = true): static | $this | Render as clickable link on index/detail. | true |
showCreateRelationButton | showCreateRelationButton(bool|\Closure $callback = true): static | $this | Show "+" button to create related record inline via modal. | false |
hideCreateRelationButton | hideCreateRelationButton(): static | $this | Explicitly hide the inline create button. | — |
modalSize | modalSize(ModalSize $size): static | $this | Set the inline create modal size. Pass any Martis\Enums\ModalSize case (Small, Medium, Large, ExtraLarge, TwoExtraLarge through SevenExtraLarge). | ModalSize::TwoExtraLarge |
iconColor | iconColor(string $color): static | $this | Color for the resource icon in the inline create modal header. Any CSS color. | accent color |
inverse | inverse(string $relationship): static | $this | The related resource's HasOne / MorphOne relationship that is the inverse of this one (Nova's inverse()): a write fails with 422 when that relationship of the picked record already holds another record. Without it, the first HasOne / MorphOne of the related resource pointing back at this resource is used. See Relationships → Writes follow the pickers. | null |
Where the picker loads its options
The BelongsTo, MorphTo and Tag pickers list their options from the relatable endpoint of what declares the field, so that declaration (related resource, relatableQueryUsing(), withoutTrashed()) and the relatable hooks apply:
| The picker renders in | It asks |
|---|---|
| a resource form | GET /api/resources/{resource}/{id}/relatable/{attribute} ({id} is the record under edit, _ on a create form) |
| an Action modal | GET /api/resources/{resource}/actions/{action}/relatable/{attribute} |
| a pivot action modal | GET /api/resources/{resource}/{id}/{belongs-to-many|morph-to-many}/{relationship}/actions/{action}/relatable/{attribute} |
the attach modal of a BelongsToMany / MorphToMany (a pivot field, v1.38.0+) | GET /api/resources/{resource}/{id}/{belongs-to-many|morph-to-many}/{relationship}/pivot-fields/relatable/{attribute} |
| the pivot edit modal of an attached record (v1.38.0+) | GET /api/resources/{resource}/{id}/{belongs-to-many|morph-to-many}/{relationship}/pivot-fields/{relatedId}/relatable/{attribute} |
a Repeater row (v1.38.0+) | any of the above, with ?repeater={attribute}&repeatable={type} naming the row |
Each is gated like the surface it serves (the form, running the Action, the attach or the pivot update), then on viewAny of the related resource. See Relationships → Relation fields declared on one form only, 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 defaultBelongsTo::make('author_id', 'Author') ->relatedResource('users') // Disable peek entirelyBelongsTo::make('author_id')->noPeeking() // EquivalentBelongsTo::make('author_id')->peekable(false)The peek card content is governed by the related resource's fieldsForPreview() method,
which defaults to the detail fields (fieldsForDetail()). Override it on the resource class
to control exactly which fields appear in the peek card:
// UserResource.php (or any resource)public function fieldsForPreview(Request $request): array{ return [ Text::make('Name'), Text::make('Email'), Text::make('Role'), ];}Fields can also be shown/hidden from preview individually. By default a field visible on detail
is also visible in preview (showOnPreview falls back to showOnDetail).
| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
peekable | peekable(bool $value = true): static | $this | Enable/disable the peek preview icon. | true |
noPeeking | noPeeking(): static | $this | Disable peek. Shorthand for peekable(false). | — |
Overrides:
resolve()returns{id, title}for the related record.fill()sets the foreign-key column on the parent model.
Extra attributes: relationship, foreignKey, titleAttribute, relatedResource, relatedLabel, relationSearchable, displayAsLink, showCreateRelationButton, modalSize, peekable, placeholder, iconColor
Toolbar controls (inherited)
All nine hideXxx() setters from ControlsRelationshipToolbar are inherited
— see relationships.md § Toolbar hide flags.
On BelongsTo only the Create button (inline create) and the View
action surface are affected (per the trait scope matrix). Remember:
visible = authorized AND NOT hidden. Authorization is always the source of
truth; the hide flags can only hide, never force-visible.
src/Fields/Concerns/ControlsRelationshipToolbar.php
Tag
Type identifier: tag
Extends: Field
File: src/Fields/Tag.php
Relational tagging via BelongsToMany. Renders as tag chips with autocomplete.
Tag::make('tags', 'Tags') ->relatedResource('tags') ->titleAttribute('name') ->withPreview() ->displayAsList() ->showCreateRelationButton() ->modalSize('3xl') ->preload()| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
relatedResource | relatedResource(string $uriKey): static | $this | URI key of related resource for autocomplete API. | null |
titleAttribute | titleAttribute(string $attribute): static | $this | Display attribute on related model. An explicit one always wins. | the related resource's title() when it declares one (a titleAttribute() other than id, or its own title()), else 'name' (v2.17.0) |
withPreview | withPreview(): static | $this | Enable preview popover on hover. | false |
displayAsList | displayAsList(): static | $this | Render as vertical list instead of chips. | false |
showCreateRelationButton | showCreateRelationButton(): static | $this | Show inline create button. | false |
modalSize | modalSize(string $size): static | $this | Inline creation modal size (sm to 7xl). | '2xl' |
preload | preload(): static | $this | Preload all tags on init (for small sets). | false |
relationSearchable | relationSearchable(bool $value = true): static | $this | Enable/disable text search in the picker. Without it the picker has no search box and lists up to 100 tags, or the related resource's $relatableSearchResults (v1.38.0+; before, the flag was ignored). | true |
getRelationship | getRelationship(): string | string | Get relationship method name. | — |
getTitleAttribute | getTitleAttribute(): string | string | Get title attribute. | — |
getRelatedResource | getRelatedResource(): ?string | ?string | Get related resource URI key. | — |
hasPreview | hasPreview(): bool | bool | Check if preview enabled. | — |
isDisplayAsList | isDisplayAsList(): bool | bool | Check if list mode. | — |
isShowCreateRelationButton | isShowCreateRelationButton(): bool | bool | Check if create button shown. | — |
getModalSize | getModalSize(): string | string | Get modal size. | — |
isPreload | isPreload(): bool | bool | Check if preloading. | — |
Preview (v1.38.0+): with withPreview() a tag on the index and detail pages opens the peek card of its record after a short hover: the related resource's fieldsForPreview(), the card a BelongsTo shows (see Peek / Preview). 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 viaDeferredRelationSync.
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 useTagto 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 usageBelongsToMany::make('Tags') ->relatedResource('tags') // Full APIBelongsToMany::make('Tags', 'tags', TagResource::class) ->searchable() ->collapsable() ->collapsedByDefault() ->allowDuplicateRelations() ->showCreateRelationButton() ->modalSize('3xl') ->withSubtitles() ->dontReorderAttachables() ->relatableQueryUsing(fn ($request, $query) => $query->where('active', true)) ->fields(fn () => [ Text::make('notes', 'Notes')->nullable(), Date::make('expires_at', 'Expires At')->nullable(), ]) ->actions(fn () => [ ExtendExpiry::make(), // runs on the selected rows, see actions.md § Pivot Actions ]) ->perPage(15) ->canAttach(true) ->canDetach(true)| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
relatedResource | relatedResource(string $uriKey): static | $this | URI key of related resource. | inferred from relationship |
titleAttribute | titleAttribute(string $attribute): static | $this | Attribute on related model for display label. An explicit one always wins. | the related resource's title() when it declares one (a titleAttribute() other than id, or its own title()), else 'name' (v2.17.0) |
fields | fields(Closure(): list<Field>): static | $this | Define pivot fields (stored on the pivot table). | null |
actions | actions(Closure(Request): list<Action>): static | $this | Pivot actions for this relationship's panel only; handle() receives the selected attached records, each with its pivot row. See Actions → Pivot Actions. | null |
searchable | searchable(bool $value = true): static | $this | Enable search in the attach modal. | false |
collapsable | collapsable(bool $value = true): static | $this | Make the panel collapsable. | false |
collapsedByDefault | collapsedByDefault(bool $value = true): static | $this | Start the panel collapsed. Implies collapsable. | false |
allowDuplicateRelations | allowDuplicateRelations(bool $value = true): static | $this | Allow attaching the same record multiple times. | false |
showCreateRelationButton | showCreateRelationButton(bool|Closure $callback = true): static | $this | Show inline create button in attach modal. | false |
hideCreateRelationButton | hideCreateRelationButton(): static | $this | Explicitly hide the inline create button. | — |
modalSize | modalSize(ModalSize|string $size): static | $this | Attach modal size (sm, md, lg, xl, 2xl–7xl). | '2xl' |
relatableQueryUsing | relatableQueryUsing(Closure $fn): static | $this | Filter the query for attachable records. | null |
dontReorderAttachables | dontReorderAttachables(bool $value = true): static | $this | Disable auto-sort of attachables (keep DB order). | false |
withSubtitles | withSubtitles(bool $value = true): static | $this | Show subtitles in the attach modal search results. | false |
perPage | perPage(int $perPage): static | $this | Default per-page for the inline listing. | 10 |
canAttach | canAttach(bool $value = true): static | $this | Allow attaching records through the panel. Off hides the Attach button, and the attachable list, the attach (single and batch) and the attach modal's pivot pickers answer 403 (v2.4.0). | true |
canDetach | canDetach(bool $value = true): static | $this | Allow detaching records through the panel. Off hides the Detach button and the detach endpoint answers 403 (v2.4.0). | true |
API Endpoints
| Method | Path | Action |
|---|---|---|
GET | /api/resources/{resource}/{id}/belongs-to-many/{relationship} | List attached records (paginated) |
GET | /api/resources/{resource}/{id}/belongs-to-many/{relationship}/attachable | List records available to attach |
POST | /api/resources/{resource}/{id}/belongs-to-many/{relationship}/attach | Attach a record (with optional pivot data) |
DELETE | /api/resources/{resource}/{id}/belongs-to-many/{relationship}/{relatedId}/detach | Detach a record |
PUT | /api/resources/{resource}/{id}/belongs-to-many/{relationship}/{relatedId}/pivot | Update pivot fields |
GET | /api/resources/{resource}/{id}/belongs-to-many/{relationship}/pivot-fields/relatable/{attribute} | Options of a BelongsTo / MorphTo / Tag pivot field in the attach modal (v1.38.0) |
GET | /api/resources/{resource}/{id}/belongs-to-many/{relationship}/pivot-fields/{relatedId}/relatable/{attribute} | The same in the pivot edit modal of an attached record (v1.38.0) |
A relation picker among the pivot fields asks the panel (the two pivot-fields routes), gated like the panel and then like the attach (attachAny{Model}) or the pivot update (updatePivot{Model}). See Relationships → Relation pickers among the pivot fields.
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()returnsnullon 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)| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
relatedResource | relatedResource(string $uriKey): static | $this | URI key of the related resource. | inferred from relationship |
canCreate | canCreate(bool $value = true): static | $this | Allow creating the related record through the panel. Off hides the Create button and the create endpoint answers 403 (v2.4.0). | true |
canUpdate | canUpdate(bool $value = true): static | $this | Allow editing the related record through the panel. Off hides the Edit button and the update endpoint answers 403 (v2.4.0). | true |
canDelete | canDelete(bool $value = true): static | $this | Allow deleting the related record through the panel. Off hides the Delete button and the delete endpoint answers 403 (v2.4.0). | true |
Static factory HasOne::ofMany($name, $relationship, $resourceClass)
promotes a hasMany()->latestOfMany() relation into a
HasOneOfMany 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');| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
All HasOne setters | — | $this | Inherited. | — |
latestByTimestamp | latestByTimestamp(string $column = 'created_at'): static | $this | Orders the underlying relation by the timestamp column descending before picking the first row; a tie goes to the highest primary key. The related model's created_at / updated_at are qualified with its table; another column is used as given, so over a through relation qualify a column both tables have ('projects.published_at'). Show, Edit, Delete and the card's writes all target that record. | 'created_at' |
oldestByTimestamp | oldestByTimestamp(string $column = 'created_at'): static | $this | Ascending counterpart of latestByTimestamp(); a tie goes to the lowest primary key. | 'created_at' |
aggregateVia | aggregateVia(AggregateFunction $function, string $column = '*'): static | $this | Emits a metric tile computed across this parent's full collection (count/sum/min/max/avg), through the relation's own keys (a through relation included). On an Eloquent one-of-many relation (latestOfMany(), ofMany()), the tile and the "latest of N" pill cover every related row of the parent: the constraints written into that relation do not apply; the global scopes it removes stay removed. The tile formats the value in the user's Martis locale, with up to two decimals and no currency symbol (v2.1.0; it showed EUR for columns named like money). | disabled |
src/Fields/HasOneOfMany.php
Toolbar controls (inherited)
All nine hideXxx() setters from ControlsRelationshipToolbar are inherited
(via HasOne) — see relationships.md § Toolbar hide flags.
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();| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
All HasOne setters | — | $this | Inherited, except canCreate, which has no effect (always false; canCreate(true) logs a warning naming the field). | — |
throughBreadcrumb | throughBreadcrumb(bool $enabled = true, ?string $text = null): static | $this | Adds a "through" hint next to the section heading. Pass a custom $text to override the default label. | false |
src/Fields/HasOneThrough.php
Toolbar controls (inherited)
All nine hideXxx() setters from ControlsRelationshipToolbar are inherited
— see relationships.md § Toolbar hide flags.
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);| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
relatedResource | relatedResource(string $uriKey): static | $this | URI key of the related resource. | inferred from relationship |
perPage | perPage(int $perPage): static | $this | Default per-page for the inline listing. | 10 |
perPageOptions | perPageOptions(array $options): static | $this | Custom per-page selector options. | resolved from related resource / [5,10,25,50] |
canCreate | canCreate(bool $value = true): static | $this | Allow creating a related record through the panel. Off hides the Create button and the create endpoint answers 403 (v2.4.0). | true |
canUpdate | canUpdate(bool $value = true): static | $this | Allow editing a related record through the panel. Off hides the Edit action and the update endpoint answers 403 (v2.4.0). | true |
canDelete | canDelete(bool $value = true): static | $this | Allow deleting a related record through the panel. Off hides the Delete action and the delete endpoint answers 403 (v2.4.0). | true |
relationSearchable | relationSearchable(bool $value = true): static | $this | Enable the search input in the panel toolbar. | false |
indexDisplay | indexDisplay(HasManyIndexDisplay $mode): static | $this | Configure how the field renders when shown on the index page. | — |
showRelationIcon | showRelationIcon(bool $value = true): static | $this | Show the related-resource icon in the panel heading. | true |
showRelationCount | showRelationCount(bool $value = true): static | $this | Show the total count badge in the panel heading. | true |
badgeColor | badgeColor(string $color): static | $this | Override the count badge colour for index display. | — |
badgeIcon | badgeIcon(string $icon): static | $this | Override the panel icon. | — |
redirectAfterSave | redirectAfterSave(HasManyRedirectMode $mode): static | $this | Where to redirect after saving a related record. | — |
collapsable | collapsable(bool $value = true): static | $this | Make the panel collapsable. | false |
collapsedByDefault | collapsedByDefault(bool $value = true): static | $this | Start collapsed. Implies collapsable. | false |
src/Fields/HasMany.php
Toolbar controls (inherited)
All nine hideXxx() setters from ControlsRelationshipToolbar are inherited
— see relationships.md § Toolbar hide flags.
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();| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
All HasMany setters | — | $this | Inherited, except canCreate, which has no effect (always false; canCreate(true) logs a warning naming the field). | — |
throughBreadcrumb | throughBreadcrumb(bool $enabled = true, ?string $text = null): static | $this | Adds a "through" hint next to the section heading. | false |
countBadge | countBadge(bool $enabled = true): static | $this | Renders a count pill on the parent resource's index cell. | true |
src/Fields/HasManyThrough.php
Toolbar controls (inherited)
All nine hideXxx() setters from ControlsRelationshipToolbar are inherited
(via HasMany) — see relationships.md § Toolbar hide flags.
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();| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
relatedResource | relatedResource(string $uriKey): static | $this | URI key of the related resource. | inferred from relationship |
canCreate | canCreate(bool $value = true): static | $this | Allow creating a related record through the panel. Off hides the Create button and the create endpoint answers 403 (v2.4.0). | true |
canUpdate | canUpdate(bool $value = true): static | $this | Allow editing the related record through the panel. Off hides the Edit button and the update endpoint answers 403 (v2.4.0). | true |
canDelete | canDelete(bool $value = true): static | $this | Allow deleting the related record through the panel. Off hides the Delete button and the delete endpoint answers 403 (v2.4.0). | true |
src/Fields/MorphOne.php
Toolbar controls (inherited)
All nine hideXxx() setters from ControlsRelationshipToolbar are inherited
— see relationships.md § Toolbar hide flags.
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, '*');| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
All MorphOne setters | — | $this | Inherited. | — |
latestByTimestamp | latestByTimestamp(string $column = 'created_at'): static | $this | Orders by the timestamp descending before picking the first row; a tie goes to the highest primary key. | 'created_at' |
oldestByTimestamp | oldestByTimestamp(string $column = 'created_at'): static | $this | Ascending counterpart; a tie goes to the lowest primary key. | 'created_at' |
aggregateVia | aggregateVia(AggregateFunction $function, string $column = '*'): static | $this | Emits a metric tile computed across this parent's full collection. On an Eloquent one-of-many relation (latestOfMany(), ofMany()), the tile and the "latest of N" pill cover every related row of the parent: the constraints written into that relation do not apply; the global scopes it removes stay removed. | disabled |
src/Fields/MorphOneOfMany.php
Toolbar controls (inherited)
All nine hideXxx() setters from ControlsRelationshipToolbar are inherited
(via MorphOne) — see relationships.md § Toolbar hide flags.
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);| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
relatedResource | relatedResource(string $uriKey): static | $this | URI key of the related resource. | inferred from relationship |
perPage | perPage(int $perPage): static | $this | Default per-page for the inline listing. | 10 |
perPageOptions | perPageOptions(array $options): static | $this | Custom per-page selector options. | resolved from related resource / [5,10,25,50] |
canCreate | canCreate(bool $value = true): static | $this | Allow creating a related record through the panel. Off hides the Create button and the create endpoint answers 403 (v2.4.0). | true |
canUpdate | canUpdate(bool $value = true): static | $this | Allow editing a related record through the panel. Off hides the Edit action and the update endpoint answers 403 (v2.4.0). | true |
canDelete | canDelete(bool $value = true): static | $this | Allow deleting a related record through the panel. Off hides the Delete action and the delete endpoint answers 403 (v2.4.0). | true |
relationSearchable | relationSearchable(bool $value = true): static | $this | Enable the search input in the panel toolbar. | false |
indexDisplay | indexDisplay(HasManyIndexDisplay $mode): static | $this | Configure how the field renders when shown on the index page. | — |
showRelationIcon | showRelationIcon(bool $value = true): static | $this | Show the related-resource icon in the panel heading. | true |
showRelationCount | showRelationCount(bool $value = true): static | $this | Show the total count badge in the panel heading. | true |
badgeColor | badgeColor(string $color): static | $this | Override the count badge colour. | — |
badgeIcon | badgeIcon(string $icon): static | $this | Override the panel icon. | — |
redirectAfterSave | redirectAfterSave(HasManyRedirectMode $mode): static | $this | Where to redirect after saving a related record. | — |
src/Fields/MorphMany.php
MorphManydoes not exposecollapsable()/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();| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
types | types(array $resourceClasses): static | $this | Allowed resource families for this polymorphic relationship. Each entry is a Resource class name; the model class is derived via newModel(). | — |
titleAttribute | titleAttribute(string $attr): static | $this | Attribute used for the display label in the picker and on detail rows. | 'name' |
relationSearchable | relationSearchable(bool $value = true): static | $this | Enable / disable text search inside the record dropdown. Without it the dropdown has no search box and lists up to 100 records of the picked type, or its resource's $relatableSearchResults (v1.38.0+; before, the flag was ignored). | true |
showCreateRelationButton | showCreateRelationButton(bool|\Closure $callback = true): static | $this | Show the inline "+" button that creates a related record per type. | false |
hideCreateRelationButton | hideCreateRelationButton(): static | $this | Explicitly hide the inline create button. | — |
modalSize | modalSize(ModalSize $size): static | $this | Modal size for the inline create flow. Pass any Martis\Enums\ModalSize case (Small through SevenExtraLarge). | ModalSize::TwoExtraLarge |
nullable | nullable(): static | $this | Make the relationship optional on forms. | false |
Resolve format
{ "type": "App\\Models\\Post", "id": 42, "title": "Post Title", "resourceType": "posts"}Fill format
The frontend submits:
{ "resourceType": "posts", "id": 42 }The backend resolves the model class from the resource URI key and writes both commentable_type and commentable_id columns on the parent. The resolve format (type + id) is accepted too, which is what an edit form sends for a target the user did not change; type must name one of the field's types() (the model class or its morph-map alias), exactly as resourceType must name one of their resources, otherwise it is ignored (v1.38.0+; before, any class name was written). Any value that is not a map is ignored, so the stored target stays as it was.
Since v1.38.0 the update page and the update drawer send the MorphTo map as is. They used to reduce every { id, title } value to its id (right for a BelongsTo), which caught the MorphTo map too, so a target changed on an edit form was never saved.
Inline create
Inline create is per-type — the create button appears only after the operator picks a type. Nesting is limited to one level (no inline create inside an inline create). A readonly MorphTo (a readonly() field, an immutable() one on an update form) offers no create button (v1.38.0+; before, the button stayed live and created a record the save then dropped). The button shows only for a type the user may create: each morphTypes entry of the schema carries that type's authorizedToCreate, and showCreateRelationButton holds as soon as one type is creatable (v1.38.0+; before, every type showed the button, and the modal of a type the user cannot create failed with a 403 on its schema). The related resource's fieldsForInlineCreate() controls which fields show; falls back to fieldsForCreate().
Toolbar controls (inherited)
All nine hideXxx() setters from ControlsRelationshipToolbar are inherited — see relationships.md § Toolbar hide flags. 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(), ]);| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
relatedResource | relatedResource(string $uriKey): static | $this | URI key of the related resource. | inferred from relationship |
titleAttribute | titleAttribute(string $attribute): static | $this | Display attribute on the related model. An explicit one always wins. | the related resource's title() when it declares one (a titleAttribute() other than id, or its own title()), else 'name' (v2.17.0) |
fields | fields(Closure $closure): static | $this | Define pivot fields (stored on the morph-pivot table). | — |
actions | actions(Closure(Request): list<Action>): static | $this | Pivot actions for this relationship's panel only; handle() receives the selected attached records, each with its pivot row. See Actions → Pivot Actions. | — |
searchable | searchable(bool $value = true): static | $this | Enable search in the attach modal. | false |
collapsable | collapsable(bool $value = true): static | $this | Make the panel collapsable. | false |
collapsedByDefault | collapsedByDefault(bool $value = true): static | $this | Start collapsed. Implies collapsable. | false |
allowDuplicateRelations | allowDuplicateRelations(bool $value = true): static | $this | Allow attaching the same record multiple times. | false |
showCreateRelationButton | showCreateRelationButton(bool|Closure $callback = true): static | $this | Show the inline create button in the attach modal. | false |
hideCreateRelationButton | hideCreateRelationButton(): static | $this | Explicitly hide the inline create button. | — |
modalSize | modalSize(ModalSize $size, ?string $height = null): static | $this | Attach modal size / optional height. | '2xl' |
relatableQueryUsing | relatableQueryUsing(Closure $closure): static | $this | Filter the attachable record list. | — |
dontReorderAttachables | dontReorderAttachables(bool $value = true): static | $this | Keep DB order in the attachable list. | false |
withSubtitles | withSubtitles(bool $value = true): static | $this | Show subtitles in search results. | false |
subtitleAttribute | subtitleAttribute(string $attribute): static | $this | Column used as the subtitle. | — |
perPage | perPage(int $perPage): static | $this | Default per-page for the inline listing. | 10 |
perPageOptions | perPageOptions(array $options): static | $this | Custom per-page selector options. | resolved from related resource / [5,10,25,50] |
canAttach | canAttach(bool $value = true): static | $this | Allow attaching records through the panel. Off hides the Attach button, and the attachable list, the attach (single and batch) and the attach modal's pivot pickers answer 403 (v2.4.0). | true |
canDetach | canDetach(bool $value = true): static | $this | Allow detaching records through the panel. Off hides the Detach button and the detach endpoint answers 403 (v2.4.0). | true |
A relation picker among the pivot fields asks the panel, under /api/resources/{resource}/{id}/morph-to-many/{relationship}/pivot-fields/relatable/{attribute} in the attach modal and .../pivot-fields/{relatedId}/relatable/{attribute} in the pivot edit modal (v1.38.0+), gated like a BelongsToMany's (see Relationships → Relation pickers among the pivot fields).
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()| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
disk | disk(string $disk): static | $this | Set Laravel filesystem disk. | 'public' |
getDisk | getDisk(): string | string | Get disk name. | — |
storagePath | storagePath(string $path): static | $this | Set subdirectory within disk. | 'uploads' |
maxSize | maxSize(int $kb): static | $this | Set max file size in KB. | null |
acceptedTypes | acceptedTypes(array $mimes): static | $this | Restrict accepted file extensions. A security control, see Active content: listing an active type ('svg', 'html') accepts it. | [] |
allowActiveContent | allowActiveContent(bool $value = true): static | $this | Accept HTML, SVG, XML and script files, which are refused by default. v2.4.0+. | false |
allowsActiveContent | allowsActiveContent(): bool | bool | Whether the field accepts active content. | — |
multiple | multiple(bool $value = true): static | $this | Enable multiple file uploads (stores JSON array). | false |
isMultiple | isMultiple(): bool | bool | Check if multiple mode. | — |
preserveOriginalName | preserveOriginalName(bool $value = true): static | $this | Keep original filename (with unique suffix). | false |
sanitizeFileName | sanitizeFileName(bool|callable $sanitizer = true): static | $this | Sanitize filenames. Accepts true for default or a custom callable. | false |
showFileInfo | showFileInfo(bool $value = true): static | $this | Show/hide file info (max size, types). | true |
hideFileInfo | hideFileInfo(): static | $this | Hide file info display. | — |
deleteStoredFile | deleteStoredFile(Model $model): void | void | Delete stored file(s) from disk. | — |
buildItemRules | buildItemRules(): array | array | Validation rules for each item in multiple mode. | — |
Overrides:
resolve()returns{path, url, name}(single) or[{path, url, name}](multiple).fill()stores uploaded file, deletes old, supports multiple mode. In multiple mode the path list is JSON-encoded unless the attribute carries anarray/json/ class cast that serialises it itself (see Structured values and Eloquent casts), and onlyexistingpaths 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()addsfile,mimes:...,max:...rules, and the active-content rule below (Martis\Rules\NoActiveContent) unlessallowActiveContent()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,php3tophp8,phtml,pht,phar,asp,aspx,jsp,cgi,htaccessand the like, seeMartis\Rules\NoActiveContent::EXTENSIONS). That is the one the field gives it: the hash name takes it from the content's MIME type, andpreserveOriginalName()keeps the client's own (so a GIF with a script in it kept aslogo.htmlis 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/xmland every other+xmltype, JavaScript and PHP types), so an HTML document namedreport.pdfis 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 activeFile::make('snippet')->allowActiveContent(); // accepts every active typeacceptedTypes() is a security control, not a convenience. Without it a field takes any file that is not active content; list the types the field is for. Accept active content only when the uploads are never served from the application's origin: a private disk behind a download route that sends Content-Disposition: attachment and X-Content-Type-Options: nosniff, a separate domain, or a web-server rule for the upload directory.
The refusal also holds at fill() for a caller that skips validation: the field throws a ValidationException before it deletes the file it replaces or stores a new one, in single and multiple mode alike. The message is martis::validation.active_content (translated in en, pt_PT, pt_BR).
An Image never accepted SVG; what changes for it is the kept name: Image::make('x')->preserveOriginalName() now refuses an image uploaded under an active extension (a polyglot).
Image
Type identifier: image
Extends: File
File: src/Fields/Image.php
Image upload with thumbnail generation. Inherits all File methods.
Image::make('featured_image', 'Featured Image') ->disk('public') ->storagePath('posts/images') ->thumbnail(400, 300) ->maxSize(5120) ->acceptedTypes(['jpg', 'jpeg', 'png', 'webp']) ->multiple() ->preserveOriginalName() ->sanitizeFileName() ->nullable()| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
thumbnail | thumbnail(int $width = 300, int $height = 300): static | $this | Enable thumbnail generation (GD or Intervention Image). | disabled |
Default accepted types: jpg, jpeg, png, gif, webp, bmp (SVG excluded: XSS risk). Overrides:
resolve()returns{path, url, name, thumbnailUrl}.fill()generates thumbnail after storing image. In multiple mode the path list follows the same cast-aware storage and the same owned-paths guard asFile(see Structured values and Eloquent casts): onlyexistingpaths the record already owns are kept, and an owned path the client omits is deleted together with its thumbnail. Before v1.37.3Imagekept any client-supplied path.buildRules()usesimageinstead offile.deleteStoredFile()also deletes thumbnail.
Extra attributes: File extras + thumbnailWidth, thumbnailHeight
Code
Type identifier: code
Extends: Field
File: src/Fields/Code.php
Syntax-highlighted code editor (CodeMirror 6). Hidden from index by default.
use Martis\Enums\CodeLanguage; Code::make('config', 'Configuration') ->language(CodeLanguage::Yaml) ->json() ->rules(['json'])| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
json | json(): static | $this | Treat as JSON: pretty-print on resolve, decode on fill. | false |
language | language(CodeLanguage $language): static | $this | Set syntax-highlighting language (typed enum). | CodeLanguage::Javascript |
isJson | isJson(): bool | bool | Check if JSON mode. | — |
getLanguage | getLanguage(): CodeLanguage | CodeLanguage | Get the configured language enum case. | — |
Supported languages: dockerfile, htmlmixed, javascript, markdown, nginx, php, ruby, sass, shell, sql, twig, vim, vue, xml, yaml-frontmatter, yaml
Overrides: resolve() pretty-prints JSON; fill() decodes JSON before storing.
Extra attributes: json, language
Markdown
Type identifier: markdown
Extends: Field
File: src/Fields/Markdown.php
Markdown editor with preview. Stores raw Markdown. Hidden from index by default.
The panel renders the Markdown in the browser (marked) and sanitises the HTML
before it reaches the page, so raw HTML a user wrote in the Markdown cannot run
script for whoever opens the record. Scripts, event handlers and javascript:
URLs are removed, and so are the things that would restyle or impersonate the
panel around the content: every data-* attribute (the global tooltip reads
data-pr-* ones), the style attribute and element, id and name, forms and
form controls other than the disabled checkbox of a task list (label,
fieldset, datalist, output and every other input go), and the class
attribute except language-<name>, the class a fenced code block carries (the
stylesheet defines layout utilities such as fixed and z-50, which content
must not be able to use to paint a fake panel over the page). The zero preset
escapes HTML instead. The stored value is the Markdown as written: sanitise it
yourself if you render it outside the panel.
Markdown::make('content') ->alwaysShow() ->preset('default') ->withFiles('public')| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
alwaysShow | alwaysShow(): static | $this | Always expand content on detail (skip "Show Content" toggle). | false |
preset | preset(string $preset): static | $this | Markdown rendering preset: 'default' (GFM), 'commonmark', 'zero'. | 'default' |
withFiles | withFiles(?string $disk = null): static | $this | Enable file uploads in the editor, stored on $disk (the panel's martis.storage.disk without one). The upload endpoint takes the disk from the field and authorises the upload like the form the field is on (v2.4.0); see Resources → Attachment Uploads. | disabled |
isAlwaysShow | isAlwaysShow(): bool | bool | Check if always showing. | — |
getPreset | getPreset(): string | string | Get preset. | — |
getWithFilesDisk | getWithFilesDisk(): ?string | ?string | The uploads disk, or null when the field does not accept files. | — |
Extra attributes: alwaysShow, preset, withFiles
Trix
Type identifier: trix
Extends: Field
File: src/Fields/Trix.php
Rich text HTML editor (Trix). Stores raw HTML. Hidden from index by default.
The stored value is the HTML the editor produced, and the JSON API accepts any
string for the attribute, so the panel sanitises it before it renders the
detail view: scripts, event handlers and javascript: URLs are removed, and so
are every data-* attribute except the three a Trix attachment uses
(data-trix-attachment, data-trix-content-type, data-trix-attributes), the
style attribute and element, id and name, forms and form controls (label,
fieldset, datalist, output and every input included), and every class
except the ones Trix writes for an attachment (attachment, attachment--*,
attachment__*, attachment-gallery, attachment-gallery--*), so content
cannot borrow a layout utility class of the panel to paint over it. An attachment whose JSON names a url / href that is not an http(s) URL or
a path of the app loses its data-trix-attachment, and clicking an attachment
or a link only follows an http(s) URL. The package does not purify the HTML
on the server: the value is stored as received, so sanitise it (for example
with mews/purifier or symfony/html-sanitizer) wherever you render it outside
the panel.
Trix::make('body', 'Body') ->alwaysShow() ->withFiles('public') ->toolbarSize('sm')| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
alwaysShow | alwaysShow(): static | $this | Always expand content on detail. | false |
withFiles | withFiles(?string $disk = null): static | $this | Enable file uploads in the editor, stored on $disk (the panel's martis.storage.disk without one). The upload endpoint takes the disk from the field and authorises the upload like the form the field is on (v2.4.0); see Resources → Attachment Uploads. | disabled |
toolbarSize | toolbarSize(string $size): static | $this | Toolbar button size: 'sm', 'md', 'lg'. | null |
isAlwaysShow | isAlwaysShow(): bool | bool | Check if always showing. | — |
getWithFilesDisk | getWithFilesDisk(): ?string | ?string | The uploads disk, or null when the field does not accept files. | — |
Extra attributes: alwaysShow, withFiles, toolbarSize
KeyValue
Type identifier: key_value
Extends: Field
File: src/Fields/KeyValue.php
Dynamic key-value pair editor. Stores as JSON. Hidden from index by default.
KeyValue::make('metadata', 'Metadata') ->keyLabel('Property') ->valueLabel('Setting') ->actionText('Add Property') ->disableEditingKeys() ->disableAddingRows()| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
keyLabel | keyLabel(string $label): static | $this | Label for key column header. | 'Key' |
valueLabel | valueLabel(string $label): static | $this | Label for value column header. | 'Value' |
actionText | actionText(string $text): static | $this | Label for "add row" button. | 'Add Row' |
disableEditingKeys | disableEditingKeys(): static | $this | Prevent editing existing keys; a write drops any key outside the stored (or default) key set (v2.4.0+). | false |
disableAddingRows | disableAddingRows(): static | $this | Prevent adding new rows; a write drops a new row but keeps a renamed key (v2.4.0+). | false |
disableDeletingRows | disableDeletingRows(): static | $this | Prevent deleting rows: no row renders a delete button, and a write that leaves a stored (or default) key out gets its value back (v2.4.0+). v1.38.0+. | false |
getKeyLabel | getKeyLabel(): string | string | Get key label. | — |
getValueLabel | getValueLabel(): string | string | Get value label. | — |
getActionText | getActionText(): string | string | Get action text. | — |
isEditingKeysDisabled | isEditingKeysDisabled(): bool | bool | Check if key editing disabled. | — |
isAddingRowsDisabled | isAddingRowsDisabled(): bool | bool | Check if adding disabled. | — |
isDeletingRowsDisabled | isDeletingRowsDisabled(): bool | bool | Check if deleting disabled. v1.38.0+. | — |
A fixed set of keys. The three disable* flags are independent. To present
a map whose keys are fixed and only the values are editable (opening hours per
weekday, one row per locale), declare all three; with only
disableEditingKeys()->disableAddingRows() the operator can still delete a row,
and saving then drops that key from the stored map:
KeyValue::make('opening_hours', 'Opening hours') ->keyLabel('Day') ->valueLabel('Hours') ->disableEditingKeys() ->disableAddingRows() ->disableDeletingRows()The server enforces the flags (v2.4.0+, hardening: Nova leaves them to the form). A request does not have to go through the form, so fill() holds a payload to the key set the flags fix. That set is the stored map's keys for a record that has a map, and the field's default() keys for a new record and for a record whose stored map is empty (a closure or rows are read as the form reads them):
| Flag | What a write does |
|---|---|
disableEditingKeys() | A submitted key outside the set is dropped: a new row, and a key edited into another name. The values of the keys in the set are the user's. |
disableAddingRows() | A new row is dropped, but the form still lets a user rename a key and delete a row, so a key edited into another name is kept as a rename: a new key may take the place of a key of the set the submission leaves out, and the old key does not come back beside it. Only as many new keys as keys left out are kept (the surplus is a real new row). |
disableDeletingRows() | A key of the set the submission leaves out (a deleted row, or the old name of a key edited into another one) takes its stored (or default) value back, and an empty value restores the whole set. A rename under disableAddingRows() replaces the old key instead of restoring it. |
Nothing changes for a field without these flags: any key is stored. A key edited into another name is a new key plus a missing one: with disableEditingKeys() the renamed key is dropped and the old one is deleted (the form offers no way to type a key then), with disableDeletingRows() too the old key stays, and with disableAddingRows() alone the rename is kept. A record that has no stored map (created before the field existed, or emptied) is held to the default() keys, so its edits are saved instead of silently dropped. The stored order of the set is kept, and a restored row keeps its stored value as it was (a nested value included).
Storage format: {"key1":"value1","key2":"value2"}
Overrides: resolve() decodes to [{key, value}] rows; fill() normalizes to the associative map and stores it, JSON-encoded unless the attribute carries an array / json / class cast that serialises it itself (see Structured values and Eloquent casts).
Extra attributes: keyLabel, valueLabel, actionText, editingKeysDisabled, addingRowsDisabled, deletingRowsDisabled
Heading
Type identifier: heading
Extends: Field
File: src/Fields/Heading.php
Visual section divider. Not a data field — does not read/write model attributes. Hidden from index by default.
Heading::make('media_section', 'Media') ->content('Featured image and attachments')| Method | Signature | Returns | Description |
|---|---|---|---|
content | content(string $text): static | $this | Descriptive text below the heading. |
Overrides: resolve() returns null; fill() is a no-op.
Extra attributes: content
Hidden
Type identifier: hidden
Extends: Field
File: src/Fields/Hidden.php
Hidden form input. Invisible in UI — never shown on index or detail.
Hidden::make('return_to')Default overrides:
showOnIndex = falseshowOnDetail = 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. UseHiddenfor 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 columnThe 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', ])| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
map | map(array|Closure $map): static | $this | Value → badge type. Closure resolves once at schema build. | [] |
labels | labels(array|Closure $labels): static | $this | Value → translated display label. Closure form supported. | [] |
types | types(array|Closure $types): static | $this | Override badge type definitions (replaces defaults). | info, success, warning, danger |
addTypes | addTypes(array $types): static | $this | Merge extra badge types onto the current set. | — |
withIcons | withIcons(): static | $this | Enable icon rendering in badges. | false |
icons | icons(array|Closure $icons): static | $this | Badge type → icon name (also enables icons). | [] |
resolveBadgeUsing ⭐ | resolveBadgeUsing(Closure $fn): static | $this | Per-row override — closure receives (value, model) and returns ['type', 'label', 'icon']. | — |
getMap | getMap(): array | array | Get resolved value-to-type map. | — |
getTypes | getTypes(): array | array | Get resolved type definitions. | — |
hasIcons | hasIcons(): bool | bool | Check if icons enabled. | — |
getIcons | getIcons(): array | array | Get resolved type-to-icon map. | — |
Default badge types: info (blue), success (green), warning (yellow), danger (red)
Extra attributes: map, labels, types, withIcons, icons
⭐ Dynamic maps & per-row resolution (Martis differentials)
All static setters (map, labels, types, icons) accept a Closure that's resolved once when the schema is serialised — ideal for enum-backed maps and config-driven palettes:
Badge::make('status') ->map(fn () => StatusEnum::badgeMap()) ->labels(fn () => StatusEnum::labels());For row-specific decisions, use resolveBadgeUsing(). The closure runs during per-row serialisation and its return value (any subset of type, label, icon) is shipped verbatim to the frontend. Missing keys fall back to the static map/labels/icons lookup:
Badge::make('status') ->map(['active' => 'success', 'paused' => 'warning']) ->labels(['active' => 'Active', 'paused' => 'Paused']) ->resolveBadgeUsing(function (?string $value, Model $model) { if ($model->is_vip && $value === 'active') { return ['type' => 'vip-gold', 'label' => 'VIP ⭐', 'icon' => 'crown']; } return []; // fall back to the static map/labels for everyone else });[!important] Badge is display-only — use
Selectin formsBadgeis intentionally filtered out of create/update contexts (showOnCreation = showOnUpdate = falseby default). It is meant for index/detail display only. If you want the user to pick a value that renders as a badge afterwards, useSelectinfieldsForCreate/fieldsForUpdateand keepBadgeinfieldsForIndex/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', ]);typeuses theBadgevocabulary:info,success,warning,danger, a key you add withtypes()/addTypes(), a hex colour, or a value translated bymap(). A missing or unknown type renders the default pill.tooltipshows through the global Martis tooltip, never a nativetitle.- 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 anInvalidArgumentExceptionnaming the field attribute. - The pills are a list (
role="list"/listitem). labels(),icons(),withIcons()andresolveBadgeUsing()inherited fromBadgedo not apply: each item carries its own label.
Extra attributes: map, types
Status
Type identifier: status
Extends: Field
File: src/Fields/Status.php
Visual state/progress indicator with loading and failed states. Display-only (hidden from forms by default).
Status::make('job_status', 'Job Status') ->loadingWhen(['waiting', 'running', 'queued']) ->failedWhen(['failed', 'errored', 'cancelled'])| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
loadingWhen | loadingWhen(array $values): static | $this | Values that trigger spinner. | [] |
failedWhen | failedWhen(array $values): static | $this | Values that trigger error indicator. | [] |
getLoadingWhen | getLoadingWhen(): array | array | Get loading values. | — |
getFailedWhen | getFailedWhen(): array | array | Get failed values. | — |
Behavior: Values not in either list render as "success" (completed).
Extra attributes: loadingWhen, failedWhen
Gravatar
Type identifier: gravatar
Extends: Field
File: src/Fields/Gravatar.php
Display-only avatar from Gravatar. Generates URL from email hash. Hidden from forms by default. fromUrl() switches it to a stored avatar URL, the one mode that writes.
Gravatar::make() // default: attribute='email', label='Avatar'Gravatar::make('user_email') // custom attribute ->squared() ->size(80)| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
squared | squared(): static | $this | Display with square edges. | — |
rounded | rounded(): static | $this | Display with rounded (circle) edges. | 'rounded' |
size | size(int $size): static | $this | Avatar size in pixels. | 40 |
getShape | getShape(): string | string | Get shape ('rounded' or 'squared'). | — |
getSize | getSize(): int | int | Get size in pixels. | — |
gravatarUrl | static gravatarUrl(string $email, int $size = 40): string | string | Generate Gravatar URL from email. | — |
Overrides: resolve() returns Gravatar URL (not raw email) in email mode and the stored URL in URL mode. fill() is a no-op in email mode (the field never writes the generated URL over the email column). In URL mode (fromUrl()) it writes the submitted URL, after the seams of every field: a readonly field (a readonly() closure included) writes nothing and a fillUsing() callback takes the write over. buildRules() adds a rule in URL mode (a field that is not readonly or computed): the value must be an absolute https:// URL (martis::validation.https_url), because it is rendered as an <img src> for every viewer; an empty value passes and writes nothing. A stored http:// avatar fails the rule when the form sends it back until it is replaced (since v2.4.0).
Extra attributes: shape, avatarSize
Sparkline
Type identifier: sparkline
Extends: Field
File: src/Fields/Sparkline.php
Inline mini chart for trend visualization. Display-only (hidden from forms by default).
The chart draws at most 300 points: a longer stored series is downsampled
(the mean of each slice) in the browser, and a value that is not a finite
number is skipped. When you show the field on a form (showOnForms()), a
written series is validated: an array of at most maxPoints() numbers
(1000 unless you raise it), so an unbounded series is refused with a 422
instead of being stored. A readonly or computed field, and one with
fillUsing(), keep their own rules.
Sparkline::make('trend', 'Revenue Trend') ->data([10, 20, 15, 40, 35, 50]) ->asBarChart() ->height(40) ->chartWidth(120) ->color('#22c55e')| Method | Signature | Returns | Description | Default |
|---|---|---|---|---|
data | data(array|callable $data): static | $this | Set chart data (array of numbers or callable receiving model). | null |
asBarChart | asBarChart(): static | $this | Render as bar chart. | — |
asLineChart | asLineChart(): static | $this | Render as line chart. | 'line' |
height | height(int $px): static | $this | Chart height in pixels. | 30 |
chartWidth | chartWidth(int $px): static | $this | SVG canvas width in pixels. Renamed from width() — the base Field::width(string) now controls the index column width. | null |
color | color(string $color): static | $this | Chart line/bar color (CSS color). | '#6366f1' |
maxPoints | maxPoints(int $max): static | $this | Most numbers a written series may hold (validated when the field is shown on a form). | 1000 |
getMaxPoints | getMaxPoints(): int | int | Get the limit. | — |
getChartType | getChartType(): string | string | Get chart type. | — |
getChartHeight | getChartHeight(): int | int | Get height. | — |
getChartWidth | getChartWidth(): ?int | ?int | Get width. | — |
getChartColor | getChartColor(): string | string | Get color. | — |
Overrides: resolve() returns data array (invokes callable if set, falls back to model attribute); fill() writes the submitted points (a JSON string is decoded first; the field is hidden from forms by default, so it only runs after showOnForms()), and honours readonly() (a closure included) and fillUsing() like every field; buildRules() adds array, max:{maxPoints} and a numbers-only check.
Extra attributes: chartType, chartHeight, chartWidth, chartColor
Repeater
File: src/Fields/Repeater.php + src/Fields/Repeatable.php
Repeatable row widget backed by JSON, HasMany or ⭐ polymorphic (single child table
with a type discriminator). See the dedicated Repeater guide for the
full API. Highlights:
| Method | Signature | Description |
|---|---|---|
repeatables | repeatables(array<Repeatable>) | Register the row types available in the Add menu |
asJson | asJson(): static | Persist on a JSON-cast parent attribute |
asHasMany | asHasMany(): static | Persist via a child table with 3-way upsert |
asPolymorphic ⭐ | asPolymorphic(string $type = 'type', string $payload = 'payload') | One child table for every row type |
uniqueField | uniqueField(string) | Column used to identify rows across saves |
confirmRemoval | confirmRemoval(bool = true) | Open a confirmation modal on remove |
minRows / maxRows ⭐ | minRows(int) / maxRows(int) | Cardinality shown in the form (Add disables at the max, a notice shows below the min); add rules(['array', 'min:N', 'max:N']) to enforce it on the server |
collapsible ⭐ | collapsible(bool = true) | Add collapse chevron to every row |
collapsedByDefault ⭐ | collapsedByDefault(bool = true) | Start collapsed |
reorderable ⭐ | reorderable(bool = true, ?string $column = null) | Drag-and-drop reorder |
dependsOn ⭐ | dependsOn(array<string>) | Expose parent attributes to every inner field |
rowTemplates ⭐ | rowTemplates(array) | Pre-filled rows available in the Add menu |
Repeatable header decorations (⭐): icon, color, title (template or closure),
badgeCount. Row-level UX extras: duplicate button per row, bulk-paste modal that
parses TSV/CSV/JSON.
A BelongsTo, MorphTo or Tag in a row type lists its options, and a Select
with searchOptionsUsing() searches them, from the row (v1.38.0+): see
Repeater → Relation pickers and remote selects in rows.
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).
| Method | Signature | Description |
|---|---|---|
register | static register(Model $model, string $relationship, array $ids): void | Register a relationship sync to be executed after save. |
sync | static sync(Model $model): void | Execute all pending syncs for a model, then clear them. |
Uses WeakMap keyed by model instances for automatic garbage collection. Every controller that saves a record through its fields calls sync() right after the save: the resource's own create and update and, since v1.38.0, the HasMany / HasOne / MorphMany / MorphOne inline forms (up to v1.37.3 those saved the record and dropped the Tag values, and the rows of a HasMany or polymorphic Repeater, queued for it).
FieldContext (Enum)
File: src/FieldContext.php
| Case | Value | Description |
|---|---|---|
INDEX | 'index' | List/table view |
DETAIL | 'detail' | Show/detail view |
CREATE | 'create' | Create form |
UPDATE | 'update' | Edit form |
INLINE_CREATE | 'inline-create' | Inline creation modal |
PREVIEW | 'preview' | Preview mode |
Component Resolution Order
When rendering a field, the React frontend resolves the component in this order:
- Explicit key —
->component('custom-key')set in PHP - Override by resource —
componentRegistry.registerResourceFieldDisplay(resource, field, comp) - Override global by type —
componentRegistry.registerFieldDisplay(type, comp) - 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
- User clicks "Replicate" on
ResourceDetail - Frontend navigates to
/resources/{resource}/create?fromResourceId={id}(a resource with a create override opens that override instead, withfromResourceId; see Overrides → Override Props) ResourceCreate(or the create drawer) fetches pre-fill data fromGET /api/resources/{resource}/{id}/replicate- Form is pre-filled with source record values (File fields excluded)
- User can modify values and submit to create the new record; the create carries
fromResourceId, and the success toast reads the resource'sreplicatedMessage()(v1.38.0+; before, it always readcreatedMessage())
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}/replicateResponse:
{ "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
- BelongsTo field renders with a "+" button when
showCreateRelationButton()is enabled - Clicking "+" opens a modal with the related resource's inline create fields
- The related resource defines
fieldsForInlineCreate()for a reduced field set - On submit, the new record is created and automatically selected in the BelongsTo dropdown
- Nesting is limited to 1 level (no inline create inside an inline create): the inline-create schema turns
showCreateRelationButtonoff for itsBelongsTo,MorphToandTagfields (Tagsince v1.38.0) - A readonly field offers no "+" (v1.38.0+): a
readonly()field, animmutable()one on an update form, and theBelongsToa 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-schemaPOST /api/resources/{resource}/inline-createSchema 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 classpublic function fieldsForInlineCreate(Request $request): array{ return [ Text::make('name', 'Name')->required(), Textarea::make('description', 'Description'), ];}Falls back to fieldsForCreate() if not overridden.