Fields
Fields define the form of a Resource (or of a Page). They are declared inside fields(), grouped in Section:
public function fields(): array
{
return [
Section::make('Content')
->columns(1)
->schema([
Text::make('title')->rules('required', 'string', 'max:255'),
Toggle::make('is_published')->default(false),
]),
Section::make('Advanced')
->collapsed()
->schema([
Time::make('publish_time')->displayFormat('H:i'),
]),
];
}Section::make($label) accepts columns(int) to define the form grid and collapsed() to start folded. Each section contains an array of fields.
The Field base class
All fields inherit from Field and share these methods:
| Method | Description |
|---|---|
label(string $label) | Label shown in the form. By default, Str::headline() of the field name. |
rules(string ...$rules) | Laravel validation rules, as you would pass them to a Validator ('required', 'max:255', 'unique:posts,slug'...). |
helperText(string $text) | Short help line under the field, for behaviour the label alone does not make clear. |
placeholder(string $text) | Placeholder text of the input. |
default(mixed $value) | Value the create form arrives filled with before the user touches it. The type depends on the field (bool for Toggle, string for Select...). |
visibleWhen(string $field, string $operator, mixed $value = null) | Shows the field only when another field meets a condition. See below. |
visibleOn(string ...$operations) | Shows the field only in one operation: create or edit. |
Validation is collected automatically from all the fields of the Resource through validationRules() (which comes from the HasSchemaFields trait, shared between Resource and Page), so there is no need to declare any FormRequest.
Visible only on create or on edit
Text::make('confirmation')
->visibleOn('create')
->rules('required', 'string')A field with visibleOn('create') only appears in the create form, and its validation rules only apply there. The form of a Relation manager does not distinguish between create and edit, so visibleOn() does not filter anything there.
Conditional visibility
Text::make('category_note')
->rules('required', 'string', 'max:255')
->visibleWhen('category', 'equals', 'announcement')The operator is one of filled, empty, equals or not_equals (equals/not_equals take a third argument, the value to compare against), a small, fixed set, not composable (no and/or, no nesting). The condition is stored as data inside the schema, not as a closure, because it is evaluated twice: on the client, so the field appears and disappears live while the form is filled in, and on the server, so that a required field the user never saw can never block the submission, its rules are skipped completely, not just made optional, when its condition is false against the submitted data.
A field inside a Repeater is scoped to its own row: the condition looks at sibling fields of the same row, not at the whole form, and each row is validated independently (row 1 can require its url while row 2, with an empty label, does not).
Repeater::make('links')
->schema([
Text::make('label')->rules('nullable', 'string', 'max:255'),
Text::make('url')
->rules('required', 'url')
->visibleWhen('label', 'filled'),
])A hidden field keeps the value it already has on the client (it is not cleared automatically), so toggling a condition up and down does not lose what the user had already typed.
Field catalog
Text
Single-line input.
Text::make('title')->rules('required', 'string', 'max:255')masked() turns it into a password field, which hides what is typed and stops the browser from autofilling it:
Text::make('password')->masked()->rules('required', 'string', 'min:12')Textarea
Multi-line text input. Same API as Text (only label()/rules()).
Textarea::make('body')->rules('nullable', 'string')RichEditor
WYSIWYG editor (Tiptap on the frontend). Its ~120 KB of JS is only loaded in panels that use this field. The content is sanitized on the server on every save() (through HtmlSanitizer), regardless of what the client sends.
RichEditor::make('content')->rules('nullable', 'string')Toggle
Boolean switch. No options of its own beyond those of the base class, default(false) is the generic default() of Field, with false as the Toggle's own default value if you declare no other.
Toggle::make('is_published')
->helperText('Published posts are visible on the public index.')
->default(false)Select
Single-option dropdown.
Select::make('category')
->options(['news' => 'News', 'update' => 'Update', 'announcement' => 'Announcement'])
->nullable()
->rules('nullable', 'string')| Method | Description |
|---|---|
options(array<string, string> $options) | value => label pairs. |
nullable(bool $nullable = true) | Allows leaving it empty on the frontend. |
Keys can be integers, and the dropdown keeps the order in which you declare the options.
CheckboxList
Multiple selection over the same options format as Select. When there is more than one option, it shows a checkbox to tick or untick them all. Inside a folded Section, the header shows how many boxes are ticked.
CheckboxList::make('notify_channels')
->options(['email' => 'Email', 'push' => 'Push', 'sms' => 'SMS'])
->rules('nullable', 'array')Tags
A free list of words, saved as an array. The user types a value and confirms it with Enter, comma or semicolon, and each value stays as a tag that can be removed. Duplicates are ignored.
Tags::make('keywords')
->itemRules('string', 'max:30')
->rules('nullable', 'array')itemRules() are the rules of each element, and rules() those of the whole array. It can be used both in a Resource form and in the schema() of an action.
Placeholder
A read-only field that shows a value inside the form, with no input. The value comes from the form data, so you will normally compute it in mutateDataBeforeFill():
Placeholder::make('summary')->label('Summary')Date and Time
Date::make('published_at')->displayFormat('d/m/Y')->rules('nullable', 'date')
Time::make('publish_time')->displayFormat('H:i')->rules('nullable', 'date_format:H:i')| Method | Description |
|---|---|
displayFormat(string $format) | Display format (PHP date() syntax). Y-m-d and H:i by default, respectively. |
Date is rendered with Arrel's DatePicker, which besides the date supports a minimum, a maximum and disabled days. Those options are properties of the component, not of the PHP field: you use them in your custom pages.
File and Image
Image is a File with a different type() (image instead of file), and they share the rest of the API.
Image::make('cover_path')
->directory('posts/covers')
->url(fn (Post $post): ?string => $post->cover_path === null ? null : "/storage/{$post->cover_path}")
->rules('nullable', 'image', 'max:4096')| Method | Description |
|---|---|
disk(string $disk) | Final destination disk. local by default. |
directory(string $directory) | Subdirectory inside the disk. |
url(Closure $callback) | Resolves a link to the file the record already has saved, so the edit form shows a "view current file". It receives the model and returns the link, or null if there is none. |
How the upload works: the frontend uploads the file to a temporary directory (arrel-temp, local disk) as soon as the user selects it, before saving the form. On save(), Arrel moves the file from arrel-temp to {disk}/{directory}/{name} and only then writes the final path to the database (see HasSchemaFields::moveUploadedFiles()). Temporary files that nobody ends up saving are cleaned up with php artisan arrel:prune-uploads (see Installation).
Repeater
Repeatable sub-schema: a list of groups of fields with the same shape.
Repeater::make('links')
->schema([
Text::make('label')->rules('required', 'string', 'max:255'),
Text::make('url')->rules('required', 'url'),
])
->rules('nullable', 'array')schema() takes an array of Field (rendered recursively: a Repeater can contain another Repeater). The validation rules of each subfield are applied with Laravel's dot notation for arrays (links.*.label, links.*.url), generated automatically, and respect each row's own visibleWhen().
Relation
A searchable dropdown over a related model. It has two modes, depending on whether the relation holds a single value or many:
Single value (belongsTo): the field is named after the foreign key column, not after the relation method, so that the normal Model::create()/update() treats it like any other attribute.
Relation::make('author_id')
->related(User::class)
->titleAttribute('name')
->searchColumns('name', 'email')
->query(fn (Builder $query): Builder => $query->whereNotNull('email_verified_at'))
->nullable()
->rules('nullable', 'exists:users,id')Multiple (belongsToMany, via ->multiple()): the field is named after the relation method (tags, that is $model->tags()), not after a column, since a pivot table has no foreign key to name it with. Use ->relationship('name') when the field name and the method name must differ.
Relation::make('tags')
->multiple()
->related(Tag::class)
->titleAttribute('name')
->rules('array')| Method | Description |
|---|---|
related(class-string<Model> $model) | The related model. Required. |
titleAttribute(string $attribute) | Attribute shown in each option of the dropdown. name by default. |
searchColumns(string ...$columns) | Columns the combobox searches against. By default, only titleAttribute(). |
query(Closure $callback) | Restricts which records can be chosen. It is also applied when saving (see below). |
searchUsing(Closure $callback, ?Closure $refine = null) | Replaces the dropdown's default search. See "Custom search". |
dependsOn(string $field) | The options depend on the value of another field of the form. See "Dependent options". |
nullable(bool = true) | Only relevant in single-value mode. |
multiple(bool = true) | Switches the field to multiple selection (chips), over a belongsToMany relation. |
relationship(string $name) | Name of the relation method, when it differs from the field name. By default, the same as the field name. |
itemRules(string ...$rules) | Validation rules for each selected element in multiple mode. By default, integer plus an exists:{table},{key} derived from related(). |
The scope is applied on save
query() does not only filter the dropdown: it is also applied when saving. A record that the scope hides is refused with a validation error, even if someone sends its identifier by hand. This holds for the forms of a Resource, of a Relation manager and of an action.
The closure receives three arguments: the query builder, the value of the dependsOn() field (or null if there is none), and the parent record when the field is inside a Relation manager or an action. It must mutate the builder it receives.
Relation::make('room_id')
->related(Room::class)
->query(function (Builder $query, mixed $dependsOn, ?Model $parent): void {
$query->where('school_id', $parent?->school_id);
})Dependent options
dependsOn() makes a field's options reload when another field of the form changes. The value of that field arrives as the second argument of the query() closure:
Relation::make('room_id')
->related(Room::class)
->dependsOn('building_id')
->query(function (Builder $query, mixed $buildingId): void {
$query->where('building_id', $buildingId);
})Custom search
By default, the dropdown searches with a LIKE over searchColumns(). searchUsing() replaces it for columns that cannot be compared that way, such as an encrypted one:
Relation::make('student_id')
->related(Student::class)
->searchUsing(
fn (Builder $query, string $term): Builder => $query->where('name_index', 'like', substr($term, 0, 3).'%'),
refine: fn (Student $student, string $term): bool => str_contains(mb_strtolower($student->name), mb_strtolower($term)),
)The first closure narrows the candidates in SQL (up to 500). The second, refine, decides in PHP which of those candidates really match. Without refine, the result is whatever the first closure leaves on the builder.
How a multiple relation is saved: when creating/updating the record, Arrel does a sync() (a full replacement, not an addition) against the relation, before afterCreate()/afterUpdate() run, so those hooks always see the relation already synced. Since sync() replaces the whole set of pivot rows, any extra pivot column is reset in the resynced rows, editing pivot data is the job of a Relation manager, not of this field. A key that is simply absent from the request payload is never touched, so a partial update cannot accidentally delete an existing relation.
Quick reference
| Field | type() | Own options |
|---|---|---|
Text | text | masked() |
Textarea | textarea | None |
RichEditor | rich_editor | None |
Toggle | toggle | None |
Select | select | options(), nullable() |
CheckboxList | checkbox_list | options() |
Tags | tags | itemRules() |
Placeholder | placeholder | None |
Date | date | displayFormat() |
Time | time | displayFormat() |
File | file | disk(), directory(), url() |
Image | image | disk(), directory(), url() |
Repeater | repeater | schema() |
Relation | relation / relation_multiple | related(), titleAttribute(), searchColumns(), searchUsing(), query(), dependsOn(), nullable(), multiple(), relationship(), itemRules() |
All fields also share helperText(), placeholder(), default(), visibleWhen() and visibleOn() from the base class.