Form Field Reactivity
Form fields in Tagixo support reactive behavior — fields that update each other in response to user input. The system has three layers, each more powerful than the previous, and you pick the lowest layer that does the job. Higher layers exist for what the lower ones cannot express.
The three layers
- Declarative actions — set / clear / copy values with conditions and transforms. No PHP. Covers about 80% of CRUD reactivity.
- Sandboxed expressions — Symfony ExpressionLanguage with a whitelisted built-in library. Computed values, conditionals, string transforms.
- Plugin registry — register additional functions into the sandbox from your application code. Extensibility hook for domain-specific helpers.
Where it lives in the drawer
Every form field that extends FormModule shows a Reactivity tab in the drawer alongside Content / Validation / Design. The tab writes to:
props.reactivity = {
live: bool,
live_mode: 'instant' | 'on_blur' | 'debounced',
debounce: int, // ms
on_state_updated: [Action, ...],
on_state_hydrated: [Action, ...],
before_dehydrated: [Action, ...],
}
At Filament render time, that JSON is rehydrated into afterStateUpdated, afterStateHydrated, and beforeStateDehydrated closures attached to the live field.
Level 1 — Declarative actions
Each action is a flat associative array:
[
'action_type' => 'set_value' | 'clear_field' | 'copy_state' | 'set_expression',
'target_field' => string, // field name to write to
'static_value' => ?string, // for set_value
'expression' => ?string, // for set_expression
'transform' => 'none' | 'upper' | 'lower' | 'slug' | 'trim' | 'int' | 'float' | 'bool',
'condition_field' => ?string, // blank = always run
'condition_operator' => '==' | '!=' | 'empty' | 'not_empty',
'condition_value' => ?string,
]
Action types
| Type | What it does |
|---|---|
set_value |
Writes transform(static_value) to target_field |
clear_field |
Writes null to target_field |
copy_state |
Writes transform($currentFieldState) to target_field |
set_expression |
Writes transform(eval(expression)) to target_field |
Transforms
none, upper, lower, slug, trim, int, float, bool. All applied to the value produced by the action before it hits the target field.
Conditions
Every action can be gated by one comparison against another field. Operators: ==, !=, empty, not_empty. Leave condition_field blank to always run.
Equality comparisons are intentionally loose (boolean-aware, numeric-aware, otherwise string-cast) because the drawer collects everything as strings. This matches user intuition ("true" == true is true).
Level 2 — Sandboxed expressions
For values that need real computation, set action_type = 'set_expression' and write a Symfony ExpressionLanguage expression in the expression field.
Available variables
| Name | Meaning |
|---|---|
state |
Current value of the field that triggered the hook |
_get |
Filament Get helper (used internally by get(path)) |
Built-in functions
| Function | Purpose |
|---|---|
get(path) |
Read another field's value |
slug(v), upper(v), lower(v), title(v), trim(v) |
String transforms |
length(v) |
mb_strlen |
contains(h, n), starts_with(h, n), ends_with(h, n) |
String predicates |
replace(v, search, replace) |
String replace |
coalesce(...) |
First non-null argument |
if(cond, a, b) |
Conditional |
int(v), float(v), bool(v) |
Type casts |
min(...), max(...), round(v, p) |
Arithmetic |
Adding to this list requires a code change — that's the whole point of the sandbox. For runtime-registered helpers, use Level 3.
Example expressions
# Auto-fill display name from first + last
get('first_name') ~ ' ' ~ get('last_name')
# Generate username slug from email local-part
slug(replace(get('email'), '@', '-'))
# Conditional default
coalesce(get('display_name'), get('email'), 'anonymous')
# Length-gated transform
length(state) > 5 ? upper(state) : state
Level 3 — Plugin registry
When the built-ins don't cover your domain (e.g. VAT validation, in-house currency formatters, tenant lookup), register custom functions through the facade:
use Ccast\TagixoFilament\Facades\TagixoReactivity;
// Single function
TagixoReactivity::register('vat_format', fn (string $vat) => strtoupper(trim($vat)));
// Bulk
TagixoReactivity::registerMany([
'currency' => fn (float $amount) => number_format($amount, 2, ',', '.'),
'fiscal_year' => fn (string $date) => (int) substr($date, 0, 4),
]);
Provider classes
For larger function sets or per-tenant function bundles, implement ReactivityFunctionProvider:
use Ccast\TagixoFilament\FormBuilder\Reactivity\Contracts\ReactivityFunctionProvider;
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
class AcmeReactivityProvider implements ReactivityFunctionProvider
{
public function getFunctions(): array
{
return [
ExpressionFunction::fromPhp('strtoupper', 'shout'),
new ExpressionFunction(
'tenant_id',
fn () => 'null',
fn () => app('current.tenant')->id,
),
];
}
}
TagixoReactivity::addProvider(new AcmeReactivityProvider());
Providers can be deferred — registered to evaluate on each request rather than at boot — which is how you ship per-tenant function sets without leaking one tenant's helpers into another.
Security model
What the sandbox prevents:
- Method calls on objects
- The
newoperator - Access to PHP built-ins that aren't on the whitelist
eval,exec, filesystem, environment variables
What the sandbox does NOT protect against:
- Functions YOU register. Anything you push through
TagixoReactivity::register()runs as regular PHP with full privileges. You own the contract — don't accept closures as arguments, don't mutate global state, don't make blocking I/O calls. - Runaway computations. The sandbox is Turing-incomplete (no loops, no recursion) but very deep expressions still cost CPU. Keep expressions short.
Putting it together
A common pattern: a "Slug" field that auto-fills from a "Title" field when empty:
- On the Title field's Reactivity tab, add an action:
action_type = set_expressionexpression = slug(state)target_field = slugcondition_field = slug,condition_operator = empty
- Save the form. The Title field will populate
slugon every keystroke, but only whenslugis currently empty — once the user types into the slug field manually, the auto-fill stops.
That single declarative chain replaces 15 lines of imperative closure code.