Form Reactivity — Level 3: Custom Expression Functions
Recap: Reactivity Levels 1 and 2
Tagixo form reactivity operates on three levels of increasing power:
| Level | Mechanism | Scope | When to use |
|---|---|---|---|
| 1 | Livewire $refresh / wire:model |
Component re-render | Simple show/hide driven by raw field values |
| 2 | Built-in expression sandbox | Field ->reactive() + ->visible() / ->hidden() closures |
Comparisons, logical operators, string checks using the bundled function set |
| 3 | Custom registered functions | Same sandbox, extended vocabulary | Domain logic, tenant context, formatting rules that the built-ins cannot express |
This page covers Level 3 only. If you have not read the Level 1/2 docs first, do that — the concepts below assume you already understand how expressions are evaluated and where they appear in field definitions.
The TagixoReactivity Facade
TagixoReactivity is the entry point for all runtime extension of the expression sandbox.
use Ccast\Tagixo\Facades\TagixoReactivity;
// Register a single named function
TagixoReactivity::register('my_fn', fn(mixed $value): mixed => /* ... */);
// Register several functions at once (name => callable)
TagixoReactivity::registerMany([
'fmt_price' => fn(float $v, string $currency = 'EUR'): string => /* ... */,
'vat_amount' => fn(float $v, float $rate): float => /* ... */,
]);
// Register a provider class (see below)
TagixoReactivity::addProvider(MyFunctionProvider::class);
Placement: always inside boot()
Function registration must happen inside a service provider's boot() method, never in register(). The sandbox is not initialized until the application has booted, so calls made during register() are silently ignored.
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Ccast\Tagixo\Facades\TagixoReactivity;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
TagixoReactivity::register('tenant_currency', function (): string {
return app('tenant')?->currency ?? 'EUR';
});
}
}
If you prefer dedicated classes (recommended for anything beyond a trivial closure), use addProvider() and keep boot() clean:
public function boot(): void
{
TagixoReactivity::addProvider(TenantPricingProvider::class);
}
Providers are resolved from the service container, so constructor injection works normally.
Naming Rules
Format
Function names must be snake_case. The sandbox rejects any name that does not match /^[a-z][a-z0-9_]*$/. CamelCase, hyphens, and leading digits are all invalid.
Reserved built-in names
Do not shadow these names — the sandbox will prefer your registration but the change will be invisible to other developers and confusing during upgrades:
abs, ceil, floor, round, min, max, clamp, mod, pow, sqrt,
length, trim, lower, upper, starts_with, ends_with, contains, concat, substr, replace,
coalesce, if, not, and, or, in, not_in,
date_format, now, today.
Namespacing advice for reusable packages
If you are shipping a package that registers functions, prefix them with your vendor slug to avoid collisions:
myvendor_fmt_price
myvendor_vat_amount
Single-app code does not need a prefix, but choose names specific enough that a future dependency cannot shadow them accidentally.
The ReactivityFunctionProvider Interface
Providers must implement Ccast\Tagixo\Contracts\ReactivityFunctionProvider:
namespace Ccast\Tagixo\Contracts;
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
interface ReactivityFunctionProvider
{
/** @return ExpressionFunction[] */
public function getFunctions(): array;
}
getFunctions() returns an array of Symfony\Component\ExpressionLanguage\ExpressionFunction objects. You can build them in two ways:
Shortcut: ExpressionFunction::fromPhp()
Use this when a PHP function already does what you need and you do not need to customize the compiled form:
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
ExpressionFunction::fromPhp('strtolower', 'lower')
// First arg: PHP function name; second arg (optional): the name exposed in expressions
This is appropriate only for pure, stateless PHP functions. Do not use it for functions that need Laravel context — the compiler phase runs outside the container.
Full form: compiler + evaluator
new ExpressionFunction(
// Name used in expressions
'fmt_price',
// Compiler callable — receives string representations of arguments,
// returns a PHP expression as a string (used when the expression language
// compiles expressions to PHP code rather than interpreting them).
function (string $value, string $currency = "'EUR'"): string {
return "number_format((float)({$value}), 2, '.', ',') . ' ' . {$currency}";
},
// Evaluator callable — receives the variables array as first argument,
// then the actual runtime argument values.
function (array $variables, float $value, string $currency = 'EUR'): string {
return number_format($value, 2, '.', ',') . ' ' . $currency;
}
);
What each callable receives:
- Compiler callable: receives the arguments as strings (the source representation). Used only when Symfony ExpressionLanguage is in compile mode; Tagixo's sandbox currently runs in interpret mode, but you should still provide a valid compiler callable for forward compatibility.
- Evaluator callable: first argument is always the
$variablesarray (the expression context, containing all field values keyed by field name). Subsequent arguments are the values passed to the function in the expression. The$variablesarray is read-only from the function's perspective — mutating it has no effect on the sandbox state.
Deferred Providers
DeferredReactivityFunctionProvider is a marker interface that extends ReactivityFunctionProvider. When the sandbox sees a provider tagged as deferred, it delays calling getFunctions() until the first expression is actually evaluated, rather than at boot() time.
namespace Ccast\Tagixo\Contracts;
interface DeferredReactivityFunctionProvider extends ReactivityFunctionProvider
{
// No additional methods — the interface is a marker only
}
When to use deferral
- Multi-tenant applications where tenant context is not available at boot time (e.g., the tenant is resolved from the request, which has not arrived yet during boot).
- High-throughput applications where you want to avoid bootstrapping expensive dependencies (database connections, API clients) for requests that never evaluate a reactive expression.
- CLI commands (
artisan queue:work, etc.) where form expressions are never evaluated and you want to avoid loading domain infrastructure unnecessarily.
Container injection pattern
Because deferred providers are resolved from the container lazily, you can inject dependencies normally:
use Ccast\Tagixo\Contracts\DeferredReactivityFunctionProvider;
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
class TenantPricingProvider implements DeferredReactivityFunctionProvider
{
public function __construct(
private readonly TenantContext $tenant,
) {}
public function getFunctions(): array
{
$vatRate = $this->tenant->vatRate(); // resolved lazily, tenant is available now
return [
new ExpressionFunction(
'vat_amount',
fn(string $v): string => "(float)({$v}) * {$vatRate}",
fn(array $vars, float $value): float => $value * $vatRate,
),
];
}
}
Timing callout: The
TenantContextpassed to the constructor is the container binding at the moment the provider is first resolved — which is the first expression evaluation, not boot. This means the binding must be correct by that time. If your tenant resolver bindsTenantContextduringHandleIncomingRequestmiddleware (which runs before controller resolution), the timing is safe. If it binds during a controller constructor, it may not be. When in doubt, inject aClosureor a repository and resolve the tenant insidegetFunctions().
Security Rules
The expression sandbox is designed to limit what expressions can do. When you register custom functions, you extend the sandbox's capabilities, so you become responsible for the security of everything you expose.
Threat model: The expression sandbox protects against accidental damage from malformed or adversarial builder expressions entered by admin users. It is not a hardened jail against malicious server-side code injection by attackers who have already gained write access to the database. Registered PHP functions execute with full application privileges. A function that runs a shell command or queries the database can be called by anyone who can edit a field definition. Treat registered functions like public API surface, not like internal implementation.
Allowed
- Pure mathematical or string transformations with no side effects
- Reading from immutable application config (
config()) — but cache the value in the provider constructor, not inside the evaluator - Reading from a resolved, request-scoped value injected via the container (tenant context, current user locale, etc.)
- Returning scalar values:
int,float,string,bool,null,arrayof scalars
Prohibited
- Any I/O: filesystem reads/writes, network calls, database queries, cache writes
- Mutating global or static state (
config()->set(),session()->put(), etc.) - Accepting closures or callables as arguments — the expression language cannot serialize them and the sandbox will reject the expression
- Returning objects other than simple value objects with no side effects
- Spawning processes or interacting with the operating system
- Throwing exceptions that are not
\InvalidArgumentException— unexpected exceptions will crash the form render and expose a stack trace to the browser
Testing
Unit-testing expressions with ReactivitySandbox::evaluate()
use Ccast\Tagixo\Reactivity\ReactivitySandbox;
it('formats price with currency', function (): void {
$result = ReactivitySandbox::evaluate(
expression: 'fmt_price(price, currency)',
get: fn(string $field): mixed => match ($field) {
'price' => 49.9,
'currency' => 'USD',
default => null,
},
);
expect($result)->toBe('49.90 USD');
});
The get closure simulates the field-value context that the sandbox provides during a real form evaluation. Each call to get($fieldName) returns the mock value for that field.
Testing providers in isolation
use App\Providers\Reactivity\TenantPricingProvider;
it('returns correct vat_amount function', function (): void {
$tenant = Mockery::mock(TenantContext::class);
$tenant->allows('vatRate')->andReturn(0.22);
$provider = new TenantPricingProvider($tenant);
$functions = $provider->getFunctions();
expect($functions)->toHaveCount(3);
$vatAmount = collect($functions)->first(fn($f) => $f->getName() === 'vat_amount');
expect($vatAmount)->not->toBeNull();
$evaluator = $vatAmount->getEvaluator();
expect($evaluator([], 100.0))->toBe(22.0);
});
beforeEach/flush() isolation warning
If your tests register functions via TagixoReactivity::register() or addProvider(), those registrations persist for the lifetime of the process unless explicitly cleared. Use the provided flush() helper in a beforeEach or afterEach to reset state between tests:
use Ccast\Tagixo\Facades\TagixoReactivity;
beforeEach(function (): void {
TagixoReactivity::flush(); // clears all registered functions and providers
});
Failure to flush can cause tests to pass in isolation but fail when run together, because a function registered in an earlier test is still present and shadows or conflicts with a later registration.
Complete Multi-Tenant Example
1. Tenant model and context service
// app/Services/TenantContext.php
namespace App\Services;
class TenantContext
{
public function __construct(
private readonly string $currency,
private readonly float $vatRate,
private readonly string $locale,
) {}
public function currency(): string { return $this->currency; }
public function vatRate(): float { return $this->vatRate; }
public function locale(): string { return $this->locale; }
}
2. Deferred provider with three functions
// app/Reactivity/TenantPricingProvider.php
namespace App\Reactivity;
use App\Services\TenantContext;
use Ccast\Tagixo\Contracts\DeferredReactivityFunctionProvider;
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
class TenantPricingProvider implements DeferredReactivityFunctionProvider
{
public function __construct(
private readonly TenantContext $tenant,
) {}
public function getFunctions(): array
{
$currency = $this->tenant->currency();
$vatRate = $this->tenant->vatRate();
return [
// fmt_price(value) — formats a float as a price string with the tenant currency
new ExpressionFunction(
'fmt_price',
fn(string $v): string => "number_format((float)({$v}), 2, '.', ',') . ' {$currency}'",
fn(array $vars, float $value): string =>
number_format($value, 2, '.', ',') . ' ' . $currency,
),
// price_with_vat(value) — returns the gross price including tenant VAT rate
new ExpressionFunction(
'price_with_vat',
fn(string $v): string => "(float)({$v}) * " . (1 + $vatRate),
fn(array $vars, float $value): float => $value * (1 + $vatRate),
),
// vat_amount(value) — returns only the VAT portion
new ExpressionFunction(
'vat_amount',
fn(string $v): string => "(float)({$v}) * {$vatRate}",
fn(array $vars, float $value): float => $value * $vatRate,
),
];
}
}
3. Service provider wiring
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use App\Reactivity\TenantPricingProvider;
use App\Services\TenantContext;
use Ccast\Tagixo\Facades\TagixoReactivity;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// TenantContext is bound during HandleIncomingRequest middleware,
// but we register the binding shape here so the container knows how
// to build it when the deferred provider is eventually resolved.
$this->app->scoped(TenantContext::class, function (): TenantContext {
// In production this is populated by your tenant resolution middleware.
// This fallback is used in tests and CLI.
return new TenantContext(
currency: config('tenant.default_currency', 'EUR'),
vatRate: (float) config('tenant.default_vat_rate', 0.20),
locale: config('tenant.default_locale', 'en'),
);
});
}
public function boot(): void
{
TagixoReactivity::addProvider(TenantPricingProvider::class);
}
}
Make sure AppServiceProvider is listed in bootstrap/providers.php:
// bootstrap/providers.php
return [
App\Providers\AppServiceProvider::class,
];
4. Using the functions in a builder expression
Inside any Tagixo/Filament field definition that supports reactive expressions:
use Ccast\Tagixo\Primix\Fields\MoneyField;
use Primix\Forms\Components\Placeholder;
// Show the gross price only when a net price has been entered
Placeholder::make('gross_price_preview')
->content('price_with_vat(net_price)')
->visible('net_price > 0'),
// Show a formatted price string
Placeholder::make('formatted_price')
->content('fmt_price(net_price)'),
// Show the VAT amount in a helper text
MoneyField::make('net_price')
->helperText('vat_amount(net_price)'),
5. Full Pest test
// tests/Unit/Reactivity/TenantPricingProviderTest.php
use App\Reactivity\TenantPricingProvider;
use App\Services\TenantContext;
use Ccast\Tagixo\Facades\TagixoReactivity;
use Ccast\Tagixo\Reactivity\ReactivitySandbox;
beforeEach(function (): void {
TagixoReactivity::flush();
});
function makeTenant(
string $currency = 'EUR',
float $vatRate = 0.22,
string $locale = 'it',
): TenantContext {
return new TenantContext($currency, $vatRate, $locale);
}
it('registers three functions', function (): void {
$provider = new TenantPricingProvider(makeTenant());
expect($provider->getFunctions())->toHaveCount(3);
$names = array_map(fn($f) => $f->getName(), $provider->getFunctions());
expect($names)->toContain('fmt_price', 'price_with_vat', 'vat_amount');
});
it('fmt_price formats a float with the tenant currency', function (): void {
$provider = new TenantPricingProvider(makeTenant(currency: 'USD'));
TagixoReactivity::addProvider($provider);
$result = ReactivitySandbox::evaluate(
expression: 'fmt_price(net_price)',
get: fn(string $field): mixed => $field === 'net_price' ? 1234.5 : null,
);
expect($result)->toBe('1,234.50 USD');
});
it('price_with_vat applies the tenant vat rate', function (): void {
$provider = new TenantPricingProvider(makeTenant(vatRate: 0.22));
TagixoReactivity::addProvider($provider);
$result = ReactivitySandbox::evaluate(
expression: 'price_with_vat(net_price)',
get: fn(string $field): mixed => $field === 'net_price' ? 100.0 : null,
);
expect($result)->toBe(122.0);
});
it('vat_amount returns only the vat portion', function (): void {
$provider = new TenantPricingProvider(makeTenant(vatRate: 0.10));
TagixoReactivity::addProvider($provider);
$result = ReactivitySandbox::evaluate(
expression: 'vat_amount(net_price)',
get: fn(string $field): mixed => $field === 'net_price' ? 200.0 : null,
);
expect($result)->toBe(20.0);
});
it('uses mocked TenantContext via container', function (): void {
$tenant = Mockery::mock(TenantContext::class);
$tenant->allows('currency')->andReturn('CHF');
$tenant->allows('vatRate')->andReturn(0.077);
$this->app->instance(TenantContext::class, $tenant);
TagixoReactivity::addProvider(TenantPricingProvider::class);
$result = ReactivitySandbox::evaluate(
expression: 'fmt_price(net_price)',
get: fn(string $field): mixed => $field === 'net_price' ? 50.0 : null,
);
expect($result)->toBe('50.00 CHF');
});