June 21, 2026

Forms in Lattice: fields you choose, not columns you inherit

By Manuel Christlieb — Staff Engineer

Part 3 of the Building Lattice series. Part 2 covered the rendering contract.

Forms are where the “decoupled from the data model” idea earns its keep. A lot of server-driven tools generate a form from a model: point at User, get inputs for its columns. Convenient, until the form needs to be anything other than the table. In Lattice a form is the fields you declare and a handle() you write. Nothing is inferred from a model.

A form is two methods

Extend FormDefinition, give it a stable id with #[Form], and implement definition() (the fields) and handle() (the submission):

#[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', 'email']),
        ]);
    }

    public function handle(Request $request): Response
    {
        $validated = $this->validate($request);

        $request->user()->update($validated);

        return redirect('/profile');
    }
}

Notice what’s not there: no model is mentioned in definition(). The form describes two fields and their rules. Whether those map to a users row, two different tables, or an external API is entirely up to handle(). The form and the persistence are separate on purpose.

Validation runs live, with the same rules

The rules you put on a field run on the server — but also live, as the user types, through Laravel’s Precognition. There’s no second copy of the validation in JavaScript; the client asks the server “is this valid so far?” and the server answers with the exact rules from definition(). $this->validate($request) returns the validated, cast data as a plain array — visible fields only, with hidden and disabled values stripped — and you do whatever you want with it.

Rendering and filling

A page renders a form with Form::use(), configured fluently:

Form::use(ProfileForm::class)
    ->method(HttpMethod::Patch)
    ->submitLabel('Save changes')
    ->fill(['name' => $user->name, 'email' => $user->email]);

->fill() seeds an edit form, ->method() sets the verb, ->context() passes extra data (like a record id) that handle() can read back. The edit form is the same definition as the create form — you just fill it.

Fields that react to each other

Fields can depend on other fields, evaluated on the client as you type and re-checked on the server so they can’t be bypassed:

TextInput::make('vat', 'VAT ID')
    ->visibleWhen('type', 'business')
    ->requiredWhen('country', ['DE', 'AT', 'CH']);

visibleWhen, requiredWhen, readOnlyWhen, and disabledWhen cover the common cases; pass an array to match any of several values, or an operator for other comparisons.

The trade-off, stated honestly

Not generating forms from models means you type a little more — you list the fields instead of getting them for free. I think that’s the right trade. The moment a generated form needs a field the model doesn’t have, or a layout the model can’t express, you stop fighting the generator and just… write the field. You’re never backed into the data model’s shape. That, to me, is worth a few extra lines.

Next: tables — columns in PHP, rows from anywhere.