Forms in Lattice: fields you choose, not columns you inherit
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.