Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
lattice-forms
lattice-php/lattice
claude-code
laravel
react
inertia
server-driven-ui

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.

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

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

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:

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());

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.

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:

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')),
);

Rendering on a page

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.

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin