FormSchema Basics
PandaPanel\Forms\FormSchema is the declarative description of a form: what renders, what validates, and what persists. You reach for it whenever a resource needs a create or edit page, an action needs a dialog with inputs, a relation manager needs a form, or a widget needs filters. All four build the same object, and everything below applies to all four.
A minimal form
A resource declares one:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Posts;
use App\Models\Post;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Resources\Resource;
final class PostResource extends Resource
{
protected static string $model = Post::class;
public static function form(FormSchema $schema): FormSchema
{
return $schema
->columns(2)
->schema([
TextInput::make('title')->required()->maxLength(255),
Textarea::make('excerpt')->rows(3)->columnSpanFull(),
]);
}
// table() and pages() omitted
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
The schema arrives already carrying its model class and the page it is being built for; form() fills in the components and returns it. Nothing else is needed for /admin/posts/create to render, validate, and save.
The three separable concerns
A field declares how it renders, what validates it, and whether it persists. Keeping them apart is what makes the password field work:
use PandaPanel\Forms\Components\PasswordInput;
PasswordInput::make('password')
->confirmed()
->rules(['min:8'])
->when(
$schema->getPage() === 'create',
static fn (PasswordInput $field): PasswordInput => $field->required(),
static fn (PasswordInput $field): PasswordInput => $field->optionalWhenFilled(),
);2
3
4
5
6
7
8
9
10
required() on create, optional on edit, still validated when filled, and not persisted when blank — so the stored hash is never overwritten with an empty string.
Validation is Laravel's. required on a field is a UX marker; removing it in the browser changes nothing. Only declared fields are validated, and only fields that dehydrate are persisted, so an extra key in the request body is discarded rather than mass-assigned.
FormSchema, method by method
FormSchema is final. Every method returns $this unless the return type says otherwise.
| Method | Signature | What it does |
|---|---|---|
make() | static make(): self | A new, empty schema. One column, page create, no model |
schema() | schema(array $components): self | Replaces the top-level components. Re-indexed with array_values() |
columns() | columns(int $columns): self | Divides the root grid. Clamped to 1–4 |
model() | model(string $modelClass): self | The Eloquent class relation-backed fields resolve against |
forPage() | forPage(string $page): self | Which page is being built — 'create', 'edit', or your own key |
getPage() | getPage(): string | The page. Default 'create' |
getModelClass() | getModelClass(): ?string | The model class, or null when none was set |
fields() | fields(?Model $record = null): array | Every Field visible on the current page, flattened out of the layouts |
getComponents() | getComponents(): array | The top-level components, for a caller merging two schemas |
field() | field(string $name): ?Field | One visible field by name, or null |
wizard() | wizard(): ?Wizard | The form's Wizard, if it is one |
validationRules() | validationRules(?Model $record = null): array | The whole Laravel rule set |
validationRulesForStep() | validationRulesForStep(int $step, ?Model $record = null): array | The subset belonging to one wizard step |
relationshipGroups() | relationshipGroups(): array | The Relationship layouts anywhere in the tree |
dehydrate() | dehydrate(array $validated, ?Model $record = null): array | Validated input turned into attributes to write |
saveRelations() | saveRelations(Model $record, array $validated): void | Writes related records and pivot rows, after the record exists |
toArray() | toArray(?Model $record = null): array | ['columns' => int, 'schema' => list<array>] — what crosses the wire |
toArrayWithState() | toArrayWithState(?Model $record, array $state): array | The same, with submitted values applied over the field values |
Used outside a resource page, the whole cycle is six lines:
use App\Models\Post;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\FormSchema;
$schema = FormSchema::make()
->model(Post::class)
->forPage('create')
->schema([TextInput::make('title')->required()]);
$rules = $schema->validationRules(); // ['title' => ['required', 'string', 'max:255']]
$data = validator(request()->all(), $rules)->validate();
$attributes = $schema->dehydrate($data); // ['title' => 'Hello']
$post = Post::query()->create($attributes);
$schema->saveRelations($post, $data);2
3
4
5
6
7
8
9
10
11
12
13
14
15
toArray() is what a page hands to Inertia:
$form = $schema->toArray($record);
// ['columns' => 2, 'schema' => [['component' => 'field', 'name' => 'title', ...]]]2
__call() gives a wrong receiver a sentence
Calling a field method on the schema raises BadMethodCallException naming the mistake rather than only the class:
FormSchema::make()->columnSpanFull();
// columnSpanFull() belongs to a field, not to the form schema. Move it onto
// the component you meant: …2
3
The translated calls are columnSpan, columnSpanFull, hidden, visible, required, and disabled. Anything else gets the ordinary "Call to undefined method" message, because a guess dressed up as a suggestion is worse than no suggestion.
The field catalogue
Every field extends PandaPanel\Forms\Components\Field and is constructed with Field::make(string $name). The name is the request key, the rule key, and — unless dehydrateTo() says otherwise — the column.
| Class | FieldType | Value shape | Page |
|---|---|---|---|
TextInput | text | ?string | Text |
Textarea | textarea | ?string | Text |
PasswordInput | password | ?string, never sent back | Text |
NumberInput | number | int|float|null | Number |
HiddenInput | hidden | untouched | Disabled and hidden |
Slider | slider | float|int|null | Slider |
ColorPicker | color_picker | ?string | Color |
TagsInput | tags_input | list<string> | Tags |
KeyValue | key_value | array<string, string> | Key value |
Checkbox | checkbox | bool | Checkbox |
Toggle | toggle | bool | Toggle |
Select | select | scalar or list<string> | Select |
Radio | radio | string|int|null | Radio |
CheckboxList | checkbox_list | list<string> | Checkbox |
ToggleButtons | toggle_buttons | scalar or list<string> | Toggle |
DatePicker | date | ?string (Y-m-d) | Date |
DateTimePicker | datetime | ?string (Y-m-d\TH:i) | Date |
TimePicker | time | ?string (H:i) | Date |
RichEditor | rich_editor | ?string HTML, sanitized | Rich editor |
MarkdownEditor | markdown_editor | ?string | Markdown |
CodeEditor | code_editor | ?string | Code editor |
FileUpload | file_upload | path, or list of paths | File uploads |
Repeater | repeater | list<array> | Repeater |
Builder | builder | list<array{type, data}> | Builder |
CustomField | custom | whatever your component emits | Custom fields |
What every field can do
These live on Field and are available on all of the above.
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Enums\ConditionOperator;
TextInput::make('slug')
->label('URL slug') // label(string): static
->placeholder('hello-world') // placeholder(string): static
->helperText('Lowercase, no spaces') // helperText(string): static
->required() // required(bool = true): static
->disabled(false) // disabled(bool = true): static
->default('hello-world') // default(mixed): static
->columnSpan(2) // columnSpan(int): static
->inlineLabel() // inlineLabel(bool = true): static
->rules(['alpha_dash']) // rules(list<mixed>): static
->rulesUsing(static fn (?Model $record): array => [])
->hiddenOn(['create']) // hiddenOn(list<string>): static
->visibleOn(['edit']) // visibleOn(list<string>): static
->disabledOn(['edit']) // disabledOn(list<string>): static
->visible(static fn (?Model $record): bool => true)
->hidden(false) // hidden(Closure|bool = true): static
->visibleWhen('kind', ConditionOperator::Equals, 'page')
->hiddenWhen('locked') // hiddenWhen(string, ConditionOperator = Truthy, mixed = null)
->live(onBlur: true, debounce: 750) // live(bool = false, ?int = null): static
->formatUsing(static fn (mixed $value, ?Model $record): mixed => $value)
->afterStateHydrated(static function (mixed $value, ?Model $record): void {})
->afterStateUpdated(static function (mixed $new, mixed $old, ?Model $record): void {})
->dehydrateStateUsing(static fn (mixed $value, ?Model $record): mixed => $value)
->mutateUsing(static fn (mixed $value, ?Model $record): mixed => $value)
->dehydrateWhen(static fn (mixed $value): bool => $value !== '')
->dehydrated(true) // dehydrated(Closure|bool = true): static
->dehydrateTo('url_slug'); // dehydrateTo(string): static2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Field also uses Laravel's Conditionable, so when() and unless() are available for page-dependent configuration without an if statement breaking the chain.
The readers a page or an endpoint calls:
| Method | Returns | Notes |
|---|---|---|
type() | FieldType | The discriminant the frontend switches on |
getName() | string | Prefixed by the relation when inside a Relationship |
getAttribute() | string | The bare attribute, without the relation prefix |
getLabel() | string | Str::headline($name) when no label was set |
getDehydrateKey() | string | dehydrateTo(), or the name |
isHiddenOn(string $page, ?Model $record = null) | bool | Server-side visibility, all sources combined |
isDisabledOn(string $page, ?Model $record = null) | bool | |
matchesConditions(array $state) | bool | The browser-side conditions, answered on the server |
isLive() | bool | |
isDehydrated(?Model $record = null) | bool | |
shouldDehydrate(mixed $value) | bool | dehydrateWhen()'s answer |
formValue(?Model $record) | mixed | The value the form is populated with |
mutate(mixed $value, ?Model $record) | mixed | The value on its way to the record |
validationRules(?Model $record) | list<mixed> | |
elementRules() | list<mixed> | Rules for field.*, empty for a scalar field |
nestedRules(?Model $record = null) | array<string, list<mixed>> | A repeater's items.*.title |
fields() | list<Field> | Itself, for everything but a container |
toArray(?Model $record, string $page) | ?array | Null when hidden on that page |
Layout
Containers divide a row; fields say how much of that division they take.
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\Layouts\Section;
Section::make('Details')
->columns(3)
->schema([
TextInput::make('first_name'), // one column
TextInput::make('last_name'),
TextInput::make('title')->columnSpan(2),
Textarea::make('bio')->columnSpanFull(), // the whole row
]);2
3
4
5
6
7
8
9
10
11
12
columnSpanFull() rather than columnSpan(3): the number that means "all of them" belongs to the container, and a field that spelled it out would silently become two thirds the day somebody made that section four columns. It crosses the wire as the string 'full' and becomes col-span-full.
Counts are responsive, and a declared count is the count on a wide screen:
columns(n) | base | md (768px) | lg (1024px) |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 1 | 2 | 2 |
| 3 | 1 | 2 | 3 |
| 4 | 1 | 2 | 4 |
A span is clamped against that table, separately at each breakpoint — so columnSpan(3) inside columns(4) is two columns at md and three at lg. Counts above four are clamped to four by PandaPanel\Support\ColumnCount::clamp(), because resources/js/panel/lib/grid.ts has literal Tailwind classes for one through four and an interpolated grid-cols-${n} compiles to nothing.
Spans are on fields and infolist entries only. Calling one on the schema is the __call() error above; on a layout it is PHP's ordinary "Call to undefined method", because only FormSchema translates the mistake. Either way a layout already takes the whole row wherever it appears.
The containers themselves are covered in Layouts: Section, Grid, Tabs/Tab, Wizard/Step, Callout, EmptyState, Relationship, CustomComponent.
Where a schema comes from
| Caller | How it builds one |
|---|---|
| Resource create and edit pages | Resource::form(FormSchema $schema), with model() and forPage() already applied |
| Actions | Action::schema(Closure $callback) — a Closure(?Model): FormSchema resolved per record |
| Relation managers | RelationManager::form(FormSchema $schema, Model $owner), merged with the pivot schema by RelationForm |
| Widgets | Widget::filterSchema(): ?FormSchema |
| Standalone pages | Page::filterSchema(): ?FormSchema |
The page a schema is built for matters: forPage('edit') is what makes hiddenOn(['edit']) apply, and getPage() is how a form branches without being told twice.
Notes
- Two fields with one name is refused.
validationRules()andtoArray()both call an internal uniqueness check that throwsPandaPanel\Exceptions\PanelSchemaException. Only one rule survives into the validator and only one value survives into the write, so the other field would be rendered, filled in, submitted, and discarded without a word. A relation group namespaces its children, soprofile.bioandbioare two names. - An empty field name is refused at construction.
Field::make('')throwsPanelSchemaException— the name is how the server matches a field to a value, a rule, and a request. - A hidden field is absent, not invisible. It is not in the payload, not in the rules, and not in what dehydrates, so a request that sends it cannot make it exist.
- Layouts never affect validation or persistence. Moving a field between sections, tabs, or wizard steps cannot change what the server accepts or writes.
toArray()has side effects on the schema. It hydrates relation-backed selects and fills many-to-many values, both idempotently. That is whyvalidationRules()anddehydrate()do the same rather than assuming somebody already called it.- Serialized values are JSON only. Scalars, arrays, and nulls. A closure runs on the server and its result crosses; the closure never does.