Skip to content

Conditional fields

Fields can react to the rest of the form. A condition compares another field’s value and toggles this field’s visibility, required, read-only, or disabled state. Conditions are evaluated on the client as the user types, and the same rules are re-checked on the server, so they cannot be bypassed.

Each method names the field to watch, an optional operator, and the value to compare against. Open the Tree tab on the examples below to see the conditions that are serialized to the client.

->visibleWhen() hides the field until the condition matches. Switch the account type below to Individual and the VAT ID field disappears.

Choice::make('type', 'Account type')->options([
Choice::option('Business', 'business'),
Choice::option('Individual', 'individual'),
]);
TextInput::make('vat', 'VAT ID')
->visibleWhen('type', 'business')

->requiredWhen() makes the field required only while the condition matches. With Germany selected the VAT ID is required; pick another country and it becomes optional.

Choice::make('country', 'Country')->options([
Choice::option('Germany', 'DE'),
Choice::option('Austria', 'AT'),
Choice::option('United States', 'US'),
]);
TextInput::make('vat', 'VAT ID')
->requiredWhen('country', 'DE')

->readOnlyWhen() and ->disabledWhen() toggle the read-only and disabled states the same way.

TextInput::make('coupon', 'Coupon')
->readOnlyWhen('plan', 'enterprise');
TextInput::make('coupon', 'Coupon')
->disabledWhen('billing', 'invoice');

With two arguments, the condition checks equality. Pass an array to match any of several values, or pass an operator as the middle argument for other comparisons.

TextInput::make('vat', 'VAT ID')
->visibleWhen('country', ['DE', 'AT', 'CH']);
NumberInput::make('guardian', 'Guardian name')
->requiredWhen('age', '<', 18);

The operator accepts a comparison string or an Op case (Lattice\Lattice\Core\Enums\Op):

ConditionStringOp case
equal=, ==Op::Equals
not equal!=, <>Op::NotEquals
greater>, >=Op::GreaterThan, Op::GreaterThanOrEqual
less<, <=Op::LessThan, Op::LessThanOrEqual
substringcontains, starts_with, ends_withOp::Contains, Op::StartsWith, Op::EndsWith
membershipin, not_inOp::In, Op::NotIn
presenceempty, filledOp::Empty, Op::Filled
datesbefore, afterOp::Before, Op::After

Conditions are evaluated twice: on the client as the user types, and again on the server so they cannot be bypassed. For that to be safe, both sides must coerce values identically. The contract is:

  • Equality against a boolean coerces the value the way PHP’s filter_var(…, FILTER_VALIDATE_BOOLEAN) does — 1, true, on, and yes are true, anything else is false. Against a non-boolean, values compare as strings.
  • Numeric comparisons (>, >=, <, <=) coerce both sides to numbers, so use them on numeric fields.
  • contains / starts_with / ends_with compare as strings.
  • empty / filled treat null and the empty string as empty.
  • before / after parse both sides as dates; a value that cannot be parsed never matches.

A shared truth-table fixture (tests/Fixtures/condition-operator-parity.json) is run by both a PHP test and a TypeScript test, so the server (ConditionEvaluator) and client (evaluateOp) evaluators cannot drift apart.

A field’s value can be derived from the form data instead of typed. Pass a closure to ->value() to compute it from the current FormData. The closure runs on the server whenever the inputs change. Closure parameters use Lattice’s closure evaluation utilities.

TextInput::make('total', 'Total')
->value(fn (FormData $data) => $data->float('qty') * $data->float('price'));

When the computation also needs to change other parts of the field, use ->dependsOn() to name the fields to watch and receive the field itself in the closure.

TextInput::make('total', 'Total')
->dependsOn(
['qty', 'price'],
fn (TextInput $field, FormData $data) => $field->value($data->float('qty') * $data->float('price')),
);

A computed value is authoritative by default: the closure result overwrites whatever the user typed on every resolve and on submit, so the field reads as derived.

Pass editable: true to compute a suggestion instead — the value is applied as a default the user can then override by typing. Once a field is manually edited it stops tracking the computed value.

TextInput::make('price', 'Price')
->value(fn (FormData $data) => priceFor($data->get('product')), editable: true);

Two lists control when the suggestion re-applies:

  • resetOn — naming a field here clears a manual override when that field changes, so the suggestion takes over again.
  • refreshOn — naming a field here recomputes the suggestion when that field changes, but only while the user has not overridden it.
TextInput::make('price', 'Price')
->value(fn (FormData $data) => priceFor($data->get('product')), editable: true, resetOn: ['product']);