Form Builder

Form Reactivity — Level 3

Register custom PHP functions and provider classes into the expression sandbox via TagixoReactivity.

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 $variables array (the expression context, containing all field values keyed by field name). Subsequent arguments are the values passed to the function in the expression. The $variables array 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 TenantContext passed 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 binds TenantContext during HandleIncomingRequest middleware (which runs before controller resolution), the timing is safe. If it binds during a controller constructor, it may not be. When in doubt, inject a Closure or a repository and resolve the tenant inside getFunctions().


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, array of 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');
});