Skip to content

Closure evaluation

Many Lattice APIs accept a closure where a static value would be too limiting: dynamic validation rules, computed field values, dependent fields, searchable selects, row labels, and table filters all use the same evaluator.

Closures are resolved through the Lattice\Core\Facades\Evaluate facade. Non-closure values pass through unchanged, so an API can accept Closure|T and resolve both forms consistently.

Each closure parameter is resolved in this order:

  1. A named utility with the same parameter name.
  2. A typed utility registered in the current EvaluationContext.
  3. A typed utility whose object is an instance of the requested parent class or interface.
  4. The Laravel container, for any resolvable class or interface.
  5. The parameter default value, or null when the parameter allows it.

If none of those match, Lattice throws an exception that lists the named utilities available to that closure.

Named utilities win before type resolution. This is why fn ($state) receives the named form state even without a type — and fn (FormData $state) receives that same object because the name matches first; the type annotation documents it rather than driving the resolution.

TextInput::make('slug', 'Slug')
->value(fn (FormData $state) => $state->string('name')->slug());

Field callbacks share a base context. It is used by dynamic rules, computed values, dependsOn() callbacks, and searchable select resolvers.

Utility Resolves to
$state The current FormData scope. For normal fields this is the form; for row hooks this is the row.
$get($key, $default) A helper that reads from the current FormData scope.
$value The current field’s own value from the current scope.
$component The live field instance.
FormData $data The current FormData scope.
Request $request The current request.
Any container type A service resolved from Laravel’s container.
The field class by type The live field instance, when the type matches the concrete field or one of its parent classes.
TextInput::make('total', 'Total')
->dependsOn(
['qty', 'price'],
fn (TextInput $component, FormData $state) => $component
->value($state->float('qty') * $state->float('price')),
);

$component and a typed field parameter point at the same live object. Lattice never autowires component classes from the container, so a mismatched component type is treated as unresolved instead of constructing a fresh component.

Some callbacks add more named utilities:

Hook Extra utilities
Select::searchable() $search, the query string.
Select::resolveSelectedUsing() $values, the selected value list, plus $component.
Repeater and builder row callbacks $row for the current row and $form for the whole form. A typed FormData parameter is $row.
ToggleFilter::query() A typed Eloquent Builder and $value, the submitted toggle state.
TernaryFilter::queries() A typed Eloquent Builder.
Lattice::extend() Named slot context, typed context objects, $user, $slot/typed Slot, and typed Request.
Select::make('author_id', 'Author')
->searchable(fn (string $search, Request $request) => User::query()
->where('team_id', $request->user()->current_team_id)
->where('name', 'like', "%{$search}%")
->limit(10)
->get()
->map(fn (User $user) => Select::option($user->name, (string) $user->id))
->all());

Inside row hooks, use the named $form utility when you need values outside the row:

Repeater::make('lines')
->itemLabel(fn (FormData $row, FormData $form) => $row->string('name')->isNotEmpty()
? $row->string('name')
: $form->string('currency'));

Stringable always evaluates truthy — even when it wraps an empty string — so ?: never falls through to the second operand. Check ->isNotEmpty() (or ->toString() !== '') explicitly instead.

Named slot factories also resolve any remaining class or interface from Laravel’s container. A context object is registered under its concrete type and can satisfy a parameter typed as that class, a parent class, or an implemented interface.

Form, action, and bulk-action endpoints validate the submission once, then invoke handle() through the same evaluator, with a small, purpose-built context:

Utility Resolves to
$data The validated, cast FormData for this submission.
$request The current Request.
$records The selected Collection of models — bulk actions only.
FormData $… Same as $data, by type.
Request $… Same as $request, by type.
Collection $… Same as $records, by type — bulk actions only.
Any container type A service resolved from Laravel’s container.
public function handle(FormData $data): ActionResult { /* … */ }
public function handle(FormData $data, Request $request): ActionResult { /* … */ }
public function handle(Collection $records, FormData $data): ActionResult { /* … */ } // bulk only

$data, $request, and $records are reserved names — declare only the ones you need, in any order. Outside a bulk action there is no selection to resolve, so a Collection-typed parameter falls through to the container instead, which constructs an empty Collection rather than raising an error — a Collection $records parameter on a non-bulk action silently receives nothing useful.

Closure evaluation is server-side. A closure runs when Lattice renders, validates, submits, resolves a dependent field, or handles a select/table round-trip.

For live client-side cross-field state, use the declarative condition API: visibleWhen(), requiredWhen(), disabledWhen(), and readOnlyWhen(). Those conditions serialize to the client and are re-checked on the server.

When adding a new Lattice extension point, accept Closure|T and resolve it at the moment the value is needed:

use Lattice\Core\Facades\Evaluate;
$resolved = Evaluate::resolve(
$value,
Evaluate::context()
->named('value', $currentValue)
->typed(Request::class, $request),
);

For form fields, start from the field’s evaluation context so $state, $get, $value, $component, typed FormData, typed Request, and typed component injection all stay consistent.