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']);
TextInput::make('guardian', 'Guardian name')
->requiredWhen('age', '<', 18);

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

Condition String Op case
equal =, == Op::Equals
not equal !=, <> Op::NotEquals
greater >, >= Op::GreaterThan, Op::GreaterThanOrEqual
less <, <= Op::LessThan, Op::LessThanOrEqual
substring contains, starts_with, ends_with Op::Contains, Op::StartsWith, Op::EndsWith
membership in, not_in Op::In, Op::NotIn
presence — (use the Op case) Op::Empty, Op::Filled
dates before, after Op::Before, Op::After

For presence checks, pass the Op case — ->visibleWhen('coupon', Op::Filled). A bare 'empty' or 'filled' string with no third argument is treated as a value to compare against, not an operator.

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.

Both evaluators are tested against one shared truth table, so the server and the client 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'));

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

->dependsOn() reacts to other fields changing, receiving the field itself in the closure. It can change anything about the field — its options, props, visibility, or live value via $field->value(...) — but it never locks the field the way ->value(Closure) does: a value set from inside a dependsOn callback ships to the client on the next resolve so the UI stays in sync, but a user who then types into the field wins on submit.

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

To make a computed value that reacts to specific fields and stays authoritative, use ->value() alone — it already re-evaluates on every resolve, so naming dependencies is only needed for dependsOn’s other effects (changing options, visibility, or other props).

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

Returning null is a decision: it clears the field. When a resolver has nothing to suggest — no product picked yet, no price on file — return Resolution::Keep instead and the field keeps whatever is in it, including what the user typed.

use Lattice\Form\Resolution;
TextInput::make('price', 'Price')
->value(
fn (FormData $data) => priceFor($data->get('product')) ?? Resolution::Keep,
editable: true,
resetOn: ['product'],
);

Resolution::Keep works the same way in an authoritative ->value() resolver: the field keeps its submitted value and stays non-authoritative for that pass, so validation does not overwrite it.