Use when building or editing Lattice forms in a Laravel + Inertia app — creating FormDefinition classes, adding fields (TextInput, Select, Checkbox, Choice, DateInput, NumberInput, RichEditor, Repeater), writing server-side or live Precognition validation, conditional or computed fields, rendering a form on a page with Form::use(), or handling submissions.
php artisan boost:add-skill lattice-php/lattice
Save this content to: .claude/skills/lattice-forms/SKILL.md
# Building Lattice forms
A Lattice form is a PHP class that declares its fields and handles its own submission. You define the schema once; Lattice renders the React inputs, posts to a dedicated **signed** endpoint, validates with Laravel (live, via Precognition), and runs your handler. Validation and conditions run on the server, so the client cannot bypass them.
## Defining a form
Extend `FormDefinition` and implement `definition()` (build the schema) and `handle()` (process a valid submit). The `#[AsForm('id')]` attribute gives a stable id so the form is discovered and addressed at `lattice/forms/{form}`.
```php
use Illuminate\Http\Request;
use Lattice\Form\Attributes\AsForm;
use Lattice\Form\Components\Form as FormComponent;
use Lattice\Form\Components\TextInput;
use Lattice\Form\FormDefinition;
use Symfony\Component\HttpFoundation\Response;
#[AsForm('app.profile.form')]
class ProfileForm extends FormDefinition
{
public function definition(FormComponent $form, Request $request): FormComponent
{
return $form->schema([
TextInput::make('name', 'Name')->rules(['required', 'string', 'max:255']),
TextInput::make('email', 'Email')->email()->rules(['required']),
]);
}
public function handle(Request $request): Response
{
$validated = $this->validate($request);
$request->user()->update($validated);
return redirect('/profile');
}
}
```
The schema accepts fields and layout containers (`Card`, `Grid`, `Stack`) in any nesting.
## Fields
Built-in fields live in `Lattice\Form\Components`: `TextInput`, `Textarea`, `Select`, `Choice`, `Checkbox`, `Toggle`, `DateInput`, `DateTimeInput`, `TimeInput`, `NumberInput`, `PatternInput`, `ColorPicker`, `OtpInput`, `PasswordInput`, `HiddenInput`, `RichEditor`, `FileUpload`/`SignedUpload`, `Repeater`, `Builder`. Create one with `Field::make('name', 'Label')` (label optional).
`Repeater` repeats one schema per row; `Builder` repeats **typed** rows the user picks from a set of block types — reach for it when rows aren't interchangeable (e.g. a page-builder's mixed content blocks). `Wizard::make([WizardStep::make('name', 'Label')->schema([...]), ...])` splits a form's schema into steps; it's a layout container like `Card`/`Grid`/`Stack`, not a field.
Options shared by **every** field (they extend the base `Field`):
| Method | Effect |
| --- | --- |
| `->value($v)` | Default value. A `fill()`ed / record value wins over it. |
| `->helperText($t)` / `->hint($t)` | Muted help line below the input. |
| `->required()` | Required indicator + `required` rule. |
| `->rules([...])` | Laravel validation rules for this field. |
| `->message($rule, $text)` | Custom message for one of this field's rules. |
| `->columnWidth(ColumnWidth::…)` | Grid span when the field sits in a `Grid`. |
| `->disabled()` | Non-interactive **and not submitted**. |
| `->readOnly()` | Shown, not editable. Like disabled, typed input is **not submitted** — only a server-set `->value()` survives. |
| `->hidden()` / `->visible($bool)` | Remove / show the field. |
### Select and Choice options
`Select` (a dropdown) and `Choice` (inline radio-style, for a handful of options) are populated the same way — `->options([...])` of label/value pairs built with the field's own `option()` helper, or `->enum()` from a backed enum:
```php
use Lattice\Form\Components\Choice;
use Lattice\Form\Components\Select;
Select::make('status', 'Status')
->placeholder('Pick a status')
->options([
Select::option('Active', 'active'),
Select::option('Archived', 'archived'),
]);
Select::make('status', 'Status')->enum(Status::class); // backed enum; labels from HasLabel or humanized case
Select::make('languages', 'Languages')->multiple(); // submits an array
Choice::make('plan', 'Plan')->options([
Choice::option('Free', 'free'),
Choice::option('Pro', 'pro'),
]);
```
For large datasets make the select server-driven with `->searchable()`; the closure's parameter must be named `$search` to receive the query string (utilities resolve by name, plus `FormData`/`Request` by type), and it returns options. On an edit form, add `->resolveSelectedUsing()` so the stored value(s) resolve back to labels:
```php
Select::make('author_id', 'Author')
->searchable(fn (string $search) => User::query()
->where('name', 'like', "%{$search}%")->limit(10)->get()
->map(fn (User $u) => Select::option($u->name, (string) $u->id))->all())
->resolveSelectedUsing(fn (array $values) => User::query()
->whereIn('id', $values)->get()
->map(fn (User $u) => Select::option($u->name, (string) $u->id))->all());
```
## Validation
Attach Laravel rules per field with `->rules([...])` (or `->required()`). In `handle()`, call `$this->validate($request)` — it runs the field rules and returns the validated, cast data as an array (**visible-only**; hidden values and locked (disabled or read-only) user input are stripped, but a server-set `->value()` on a locked field survives).
Turn on **live validation** by calling `->precognitive(500)` (debounce ms) in `definition()`. It validates as the user types using the exact same ruleset — nothing to keep in sync.
```php
return $form
->precognitive(500)
->schema([/* … */]);
```
Where a callback needs in-flight form state (dynamic rules, dependent fields), it receives a `FormData` object with typed accessors: `$data->string('name')`, `->boolean('subscribe')`, `->integer('qty')`, `->float('price')`, `->get('tags', [])`.
## Conditional & computed fields
Fields react to other fields. Each method names the field to watch, an optional operator, and the value to compare:
```php
TextInput::make('vat', 'VAT ID')->visibleWhen('type', 'business');
TextInput::make('vat', 'VAT ID')->requiredWhen('country', 'DE');
TextInput::make('coupon')->readOnlyWhen('plan', 'enterprise');
TextInput::make('coupon')->disabledWhen('billing', 'invoice');
```
Pass an array to match any value (`->visibleWhen('country', ['DE', 'AT', 'CH'])`), or an operator string / `Lattice\Core\Enums\Op` case as the middle argument (`->requiredWhen('age', '<', 18)`). Operators include `=`, `!=`, `>`, `>=`, `<`, `<=`, `contains`, `starts_with`, `ends_with`, `in`, `not_in`, `empty`, `filled`, `before`/`after`. Conditions are evaluated on the client **and** re-checked on the server.
Compute a value from the form data instead of typing it:
```php
TextInput::make('total', 'Total')
->value(fn (FormData $data) => $data->float('qty') * $data->float('price'));
```
To also change the field when its inputs change, name them with `->dependsOn()`:
```php
TextInput::make('total', 'Total')->dependsOn(
['qty', 'price'],
fn (TextInput $field, FormData $data) => $field->value($data->float('qty') * $data->float('price')),
);
```
## Rendering on a page
Render with `Form::use(MyForm::class)` inside a page's component tree, configured fluently:
```php
use Lattice\Ui\Enums\HttpMethod;
use Lattice\Form\Components\Form;
Form::use(ProfileForm::class)
->method(HttpMethod::Patch) // post by default
->submitLabel('Save changes')
->fill(['name' => $user->name, 'email' => $user->email]) // seeds an edit form
->context(['user_id' => $user->id]); // extra data handle() can read
```
Register a key once with `Lattice::context('user', User::class)` and read it back typed with `$this->contextModel('user')` — memoized per request, 404 when the key is absent or nothing resolves; `contextModelOrNull()` in a render-time `authorize()` instead. A registered key cascades automatically into every field/action the form builds without re-passing it, and `->context(['user' => $user])` accepts the model directly — it normalizes to the scalar before the ref seals. Gate the form itself against that same subject with `#[AsForm('id', can: 'update', on: 'user')]`.
The form renders its own submit button in a plain right-aligned row (disabled while submitting or while there are errors, with a spinner) — labelled by `->submitLabel()`, aligned with `->submitJustify(Justify::…)`, and styled with `->submitVariant(ButtonVariant::…)`. Replace the row's contents with `->submitButtons(Button::make('Cancel'), Button::make('Save')->submit())`; only a `->submit()`-marked button's label and variant carry over to the managed button, other props are ignored. To place the button yourself instead, call `->withoutSubmitButton()` (removes the row entirely) and add `Button::make('Save')->submit()` in the schema.
## Submit lifecycle
Every form posts to its signed endpoint; `FormController` routes the request: a searchable `Select` **search** returns options; a dependent-field **resolve** returns updated nodes/values; a **precognitive** request validates and returns `204`/`422` without running `handle()`; otherwise it validates and calls `handle()`. `handle()` may return any Laravel `Response`/`Responsable` — a redirect, JSON, or a toast effect via `ActionResult`.
## Common mistakes
- **No `#[AsForm('id')]` attribute** → the form is not discovered and has no endpoint.
- **Validating only in `handle()`** instead of per-field `->rules()` → no live validation; the client can't reflect the rules.
- **Expecting `disabled()` or `readOnly()` typed values in the validated data** — locked fields are stripped; only a server-set `->value()` survives. Set the value server-side in `definition()` or `handle()`.
- **Relying on a conditionally hidden field's value** — a field hidden by `visibleWhen` (or `->hidden()`) is stripped from `validate()`'s result, so it is absent from the array. Default it in `handle()` if you still need a value.
- **Changing a `#[AsForm]` id later** → breaks already-rendered references.
Need an action behind a button, a row, or a selection? See the **`lattice-actions`** skill.
A Lattice form is a PHP class that declares its fields and handles its own submission. You define the schema once; Lattice renders the React inputs, posts to a dedicated signed endpoint, validates with Laravel (live, via Precognition), and runs your handler. Validation and conditions run on the server, so the client cannot bypass them.
Extend FormDefinition and implement definition() (build the schema) and handle() (process a valid submit). The #[AsForm('id')] attribute gives a stable id so the form is discovered and addressed at lattice/forms/{form}.
use Illuminate\Http\Request;
use Lattice\Form\Attributes\AsForm;
use Lattice\Form\Components\Form as FormComponent;
use Lattice\Form\Components\TextInput;
use Lattice\Form\FormDefinition;
use Symfony\Component\HttpFoundation\Response;
#[AsForm('app.profile.form')]
class ProfileForm extends FormDefinition
{
public function definition(FormComponent $form, Request $request): FormComponent
{
return $form->schema([
TextInput::make('name', 'Name')->rules(['required', 'string', 'max:255']),
TextInput::make('email', 'Email')->email()->rules(['required']),
]);
}
public function handle(Request $request): Response
{
$validated = $this->validate($request);
$request->user()->update($validated);
return redirect('/profile');
}
}
The schema accepts fields and layout containers (Card, Grid, Stack) in any nesting.
Built-in fields live in Lattice\Form\Components: TextInput, Textarea, Select, Choice, Checkbox, Toggle, DateInput, DateTimeInput, TimeInput, NumberInput, PatternInput, ColorPicker, OtpInput, PasswordInput, HiddenInput, RichEditor, FileUpload/SignedUpload, Repeater, Builder. Create one with Field::make('name', 'Label') (label optional).
Repeater repeats one schema per row; Builder repeats typed rows the user picks from a set of block types — reach for it when rows aren't interchangeable (e.g. a page-builder's mixed content blocks). Wizard::make([WizardStep::make('name', 'Label')->schema([...]), ...]) splits a form's schema into steps; it's a layout container like Card/Grid/Stack, not a field.
Options shared by every field (they extend the base Field):
| Method | Effect |
|---|---|
->value($v) |
Default value. A fill()ed / record value wins over it. |
->helperText($t) / ->hint($t) |
Muted help line below the input. |
->required() |
Required indicator + required rule. |
->rules([...]) |
Laravel validation rules for this field. |
->message($rule, $text) |
Custom message for one of this field's rules. |
->columnWidth(ColumnWidth::…) |
Grid span when the field sits in a Grid. |
->disabled() |
Non-interactive and not submitted. |
->readOnly() |
Shown, not editable. Like disabled, typed input is not submitted — only a server-set ->value() survives. |
->hidden() / ->visible($bool) |
Remove / show the field. |
Select (a dropdown) and Choice (inline radio-style, for a handful of options) are populated the same way — ->options([...]) of label/value pairs built with the field's own option() helper, or ->enum() from a backed enum:
use Lattice\Form\Components\Choice;
use Lattice\Form\Components\Select;
Select::make('status', 'Status')
->placeholder('Pick a status')
->options([
Select::option('Active', 'active'),
Select::option('Archived', 'archived'),
]);
Select::make('status', 'Status')->enum(Status::class); // backed enum; labels from HasLabel or humanized case
Select::make('languages', 'Languages')->multiple(); // submits an array
Choice::make('plan', 'Plan')->options([
Choice::option('Free', 'free'),
Choice::option('Pro', 'pro'),
]);
For large datasets make the select server-driven with ->searchable(); the closure's parameter must be named $search to receive the query string (utilities resolve by name, plus FormData/Request by type), and it returns options. On an edit form, add ->resolveSelectedUsing() so the stored value(s) resolve back to labels:
Select::make('author_id', 'Author')
->searchable(fn (string $search) => User::query()
->where('name', 'like', "%{$search}%")->limit(10)->get()
->map(fn (User $u) => Select::option($u->name, (string) $u->id))->all())
->resolveSelectedUsing(fn (array $values) => User::query()
->whereIn('id', $values)->get()
->map(fn (User $u) => Select::option($u->name, (string) $u->id))->all());
Attach Laravel rules per field with ->rules([...]) (or ->required()). In handle(), call $this->validate($request) — it runs the field rules and returns the validated, cast data as an array (visible-only; hidden values and locked (disabled or read-only) user input are stripped, but a server-set ->value() on a locked field survives).
Turn on live validation by calling ->precognitive(500) (debounce ms) in definition(). It validates as the user types using the exact same ruleset — nothing to keep in sync.
return $form
->precognitive(500)
->schema([/* … */]);
Where a callback needs in-flight form state (dynamic rules, dependent fields), it receives a FormData object with typed accessors: $data->string('name'), ->boolean('subscribe'), ->integer('qty'), ->float('price'), ->get('tags', []).
Fields react to other fields. Each method names the field to watch, an optional operator, and the value to compare:
TextInput::make('vat', 'VAT ID')->visibleWhen('type', 'business');
TextInput::make('vat', 'VAT ID')->requiredWhen('country', 'DE');
TextInput::make('coupon')->readOnlyWhen('plan', 'enterprise');
TextInput::make('coupon')->disabledWhen('billing', 'invoice');
Pass an array to match any value (->visibleWhen('country', ['DE', 'AT', 'CH'])), or an operator string / Lattice\Core\Enums\Op case as the middle argument (->requiredWhen('age', '<', 18)). Operators include =, !=, >, >=, <, <=, contains, starts_with, ends_with, in, not_in, empty, filled, before/after. Conditions are evaluated on the client and re-checked on the server.
Compute a value from the form data instead of typing it:
TextInput::make('total', 'Total')
->value(fn (FormData $data) => $data->float('qty') * $data->float('price'));
To also change the field when its inputs change, name them with ->dependsOn():
TextInput::make('total', 'Total')->dependsOn(
['qty', 'price'],
fn (TextInput $field, FormData $data) => $field->value($data->float('qty') * $data->float('price')),
);
Render with Form::use(MyForm::class) inside a page's component tree, configured fluently:
use Lattice\Ui\Enums\HttpMethod;
use Lattice\Form\Components\Form;
Form::use(ProfileForm::class)
->method(HttpMethod::Patch) // post by default
->submitLabel('Save changes')
->fill(['name' => $user->name, 'email' => $user->email]) // seeds an edit form
->context(['user_id' => $user->id]); // extra data handle() can read
Register a key once with Lattice::context('user', User::class) and read it back typed with $this->contextModel('user') — memoized per request, 404 when the key is absent or nothing resolves; contextModelOrNull() in a render-time authorize() instead. A registered key cascades automatically into every field/action the form builds without re-passing it, and ->context(['user' => $user]) accepts the model directly — it normalizes to the scalar before the ref seals. Gate the form itself against that same subject with #[AsForm('id', can: 'update', on: 'user')].
The form renders its own submit button in a plain right-aligned row (disabled while submitting or while there are errors, with a spinner) — labelled by ->submitLabel(), aligned with ->submitJustify(Justify::…), and styled with ->submitVariant(ButtonVariant::…). Replace the row's contents with ->submitButtons(Button::make('Cancel'), Button::make('Save')->submit()); only a ->submit()-marked button's label and variant carry over to the managed button, other props are ignored. To place the button yourself instead, call ->withoutSubmitButton() (removes the row entirely) and add Button::make('Save')->submit() in the schema.
Every form posts to its signed endpoint; FormController routes the request: a searchable Select search returns options; a dependent-field resolve returns updated nodes/values; a precognitive request validates and returns 204/422 without running handle(); otherwise it validates and calls handle(). handle() may return any Laravel Response/Responsable — a redirect, JSON, or a toast effect via ActionResult.
#[AsForm('id')] attribute → the form is not discovered and has no endpoint.handle() instead of per-field ->rules() → no live validation; the client can't reflect the rules.disabled() or readOnly() typed values in the validated data — locked fields are stripped; only a server-set ->value() survives. Set the value server-side in definition() or handle().visibleWhen (or ->hidden()) is stripped from validate()'s result, so it is absent from the array. Default it in handle() if you still need a value.#[AsForm] id later → breaks already-rendered references.Need an action behind a button, a row, or a selection? See the lattice-actions skill.
How can I help you explore Laravel packages today?