Form Field Reactivity
Tagixo forms support three levels of reactivity — from declarative rules you configure in the builder drawer, to sandboxed expression evaluation, to PHP-registered callbacks for server-side lookups. Levels 1 and 2 are pure builder features requiring no additional code in your Laravel app. Level 3 requires an admin panel SDK; see the Filament or Primix SDK documentation sections if you need it.
Quick reference
| Capability | Level 1 — Declarative | Level 2 — Expression | Level 3 — Registry |
|---|---|---|---|
| Show / hide a field | Yes | Yes | Yes |
| Require / unrequire | Yes | Yes | Yes |
| Enable / disable | Yes | Yes | Yes |
| Set another field's value | No | Yes (set()) |
Yes |
| Computed math | No | Yes | Yes |
| External data / DB lookup | No | No | Yes |
| Zero PHP required | Yes | Yes | No |
| Where configured | Drawer → Reactivity | Drawer → Reactivity → Expression | SDK ReactivityRegistry |
Where reactivity is configured
Every form field exposes a Reactivity tab in the builder drawer. Inside it you will find three JSON hook keys:
| Key | Fires when… | Typical use |
|---|---|---|
on_change |
The field value changes (debounced 300 ms for text inputs) | Recalculate totals, reveal dependent fields |
on_blur |
Focus leaves the field | Format a display value, validate a pattern |
on_load |
The form first renders, and after each page navigation in multi-step forms | Pre-fill from URL parameters, set computed defaults |
Select, checkbox, radio, and date fields fire
on_changeimmediately on commit. Text and textarea inputs are debounced 300 ms to avoid reacting on every keystroke.
Each hook accepts an array of action objects evaluated in order. If two actions in the same hook write to the same target field, the last one wins.
Level 1 — Declarative actions
Declarative actions require no code. You paste JSON into the hook field in the drawer and the form runtime handles the rest. The minimum shape of an action object is { "type": "<action>" }. Conditional behaviour is added via a when clause.
Action types
type |
Effect | Required extra keys |
|---|---|---|
set_value |
Sets this field's value | value |
set_value_from |
Copies another field's current value into this field | source_field |
clear_value |
Empties this field | — |
show_field |
Makes a target field visible | target_field |
hide_field |
Hides a target field (value is preserved) | target_field |
require_field |
Marks a target field required | target_field |
unrequire_field |
Removes the required constraint from a target field | target_field |
enable_field |
Enables a disabled target field | target_field |
disable_field |
Disables (greys out) a target field | target_field |
when clause
A when clause makes an action conditional. The clause shape is:
{ "field": "<key>", "operator": "<op>", "value": "<v>" }
An action without a when clause fires unconditionally every time its parent hook runs.
| Operator | Meaning |
|---|---|
equals |
Strict equality |
not_equals |
Strict inequality |
is_empty |
Value is empty string, null, or [] |
is_not_empty |
Opposite of is_empty |
contains |
String or array contains the given value |
starts_with |
String starts with the given value |
ends_with |
String ends with the given value |
> |
Numeric greater-than |
< |
Numeric less-than |
Common patterns
Show a field only when another field has a specific value
Paste this into the on_change hook of the customer_type field. Set company_name to hidden by default in its own drawer settings so it starts invisible.
[
{
"type": "show_field",
"target_field": "company_name",
"when": { "field": "customer_type", "operator": "equals", "value": "business" }
},
{
"type": "hide_field",
"target_field": "company_name",
"when": { "field": "customer_type", "operator": "not_equals", "value": "business" }
}
]
Conditionally require a field
Two actions in the same hook handle both branches explicitly. Omitting the unrequire_field branch means the field stays required once triggered, even if the user changes their selection back.
[
{
"type": "require_field",
"target_field": "vat_number",
"when": { "field": "customer_type", "operator": "equals", "value": "business" }
},
{
"type": "unrequire_field",
"target_field": "vat_number",
"when": { "field": "customer_type", "operator": "not_equals", "value": "business" }
}
]
Clear a dependent field when a parent field changes
When a country dropdown changes, the previously selected city is no longer valid. Clearing it immediately prevents stale data from reaching your controller.
{
"type": "clear_value",
"target_field": "city",
"when": { "field": "country", "operator": "is_not_empty" }
}
Copy a value from another field
{
"type": "set_value_from",
"target_field": "billing_address",
"source_field": "shipping_address",
"when": { "field": "same_address", "operator": "equals", "value": "1" }
}
Level 2 — Sandboxed expressions
When declarative actions are not expressive enough — computed totals, formatted strings derived from multiple fields, or conditional logic with arithmetic — you can write a sandboxed expression. Expressions are evaluated by a server-side Symfony ExpressionLanguage sandbox delivered to the client as a pre-compiled evaluation tree. They are not eval() and do not execute arbitrary JavaScript.
Security model
The sandbox has no access to window, document, fetch, XMLHttpRequest, timers, cookies, localStorage, or session storage. It cannot import modules or call any function outside the built-in list below. The only permitted side-effect is set(), and it is scoped to fields within the same form.
Infinite loop guard. When field A's expression calls
set("B", …), field B's ownon_changehook fires once the current expression completes. If B's hook also writes back to A you will create an infinite loop. Wrap the write in anif()equality guard:if(get("B") != computed_value, set("B", computed_value), null).
Built-in functions
| Function | Signature | Description |
|---|---|---|
get(key) |
get("price") |
Returns the current value of another field |
set(key, value) |
set("total", 99) |
Sets another field's value as a side-effect; returns null |
number_format(n, decimals) |
number_format(1234.5, 2) |
Returns "1,234.50" |
concat(...parts) |
concat("Hello ", get("name")) |
String concatenation |
format_currency(amount, symbol) |
format_currency(9.9, "€") |
Returns "€9.90" |
if(cond, then, else) |
if(get("qty") > 0, "ok", "") |
Ternary |
round(n, decimals) |
round(1.234, 2) |
Returns 1.23 |
floor(n) |
floor(1.9) |
Returns 1 |
ceil(n) |
ceil(1.1) |
Returns 2 |
len(value) |
len(get("notes")) |
String or array length |
upper(str) |
upper(get("code")) |
Uppercase |
lower(str) |
lower(get("email")) |
Lowercase |
trim(str) |
trim(get("name")) |
Strip leading/trailing whitespace |
Expression examples
Compute a subtotal from quantity and unit price
Place this expression in the on_change hook of both the quantity and unit_price fields. The result key holds the expression; the runtime evaluates it and writes the return value into the field that owns the hook.
{
"type": "expression",
"result": "number_format(get(\"quantity\") * get(\"unit_price\"), 2)",
"target_field": "subtotal"
}
Build a formatted display name from first and last name
{
"type": "expression",
"result": "concat(trim(get(\"first_name\")), \" \", trim(get(\"last_name\")))",
"target_field": "display_name"
}
Apply a discount tier based on quantity
{
"type": "expression",
"result": "if(get(\"quantity\") >= 100, format_currency(get(\"unit_price\") * 0.85, \"€\"), if(get(\"quantity\") >= 50, format_currency(get(\"unit_price\") * 0.90, \"€\"), format_currency(get(\"unit_price\"), \"€\")))",
"target_field": "effective_price"
}
Show a character counter as a side-effect and keep the field visible
An expression can combine a return value with set() calls. The return value goes into the field that owns the hook; set() calls update other fields.
{
"type": "expression",
"result": "set(\"chars_remaining\", 500 - len(get(\"message\")))"
}
Mixing declarative actions and expressions in one hook
A hook array can contain any combination of declarative action objects and expression objects. They run in order.
[
{
"type": "expression",
"result": "set(\"total\", round(get(\"qty\") * get(\"price\"), 2))"
},
{
"type": "show_field",
"target_field": "discount_note",
"when": { "field": "qty", "operator": ">", "value": "49" }
}
]
Level 3 — PHP-registered callbacks
Level 3 allows expressions to call named PHP functions you register on the server — useful for database lookups, external API calls, or any logic that cannot run in the browser sandbox. Registering these callbacks requires an admin panel SDK (Filament or Primix). See the Filament or Primix SDK documentation sections for the registration API and lifecycle details.
See also
- Custom Form Modules — building your own field types from scratch
- Storage Model and Data Shape — how form definitions and submissions are stored
- Standalone Page Builder Integration — wiring up builder endpoints in your own Laravel controllers
- PropTypes System — the prop definition layer that powers field configuration in the drawer