Functions & States
Two small but central pieces: Functions are reusable units of logic a flow can call; States are persistent, app-wide variables that pages bind to reactively and flows update live.
Functions
A function is a row in page_builder_functions (src/Models/FlowFunction.php), edited under Functions in the admin and invoked by the function flow node. Route key slug; soft-deletes.
| Column | Notes |
|---|---|
slug |
^[a-z][a-z0-9_-]*$ |
name |
Label |
description |
Optional |
runtime |
expression | callable | php |
body |
Code/expression/key, depending on runtime |
The function node passes interpolated args and writes the return value to vars[output].
expression runtime — the recommended, eval-free path
A Symfony ExpressionLanguage expression — a safe sandbox with no raw PHP and no eval. Its only callable surface is state()/global() plus the curated helper library (db_*, ui_*, auth_*, util_*). This is the recommended way to express logic: a function composes documented, allow-listed helpers rather than executing arbitrary code, so it gets real power (read/write collections, pop toasts, guard business rules) while staying safe. See the syntax reference for operators, literals and indexing.
The expression sees these variables:
input— the flow inputvars— accumulated flow varsargs— the node'sargsstates/globals— the persistent State map
Plus state('key') / global('key') (alias) to read a persistent State, and every helper in the helper library.
args["price"] * 1.2state('tax_rate') * args["subtotal"]db_create('orders', {customer_id: auth_id(), total: vars.total, ref: util_uuid()})Errors propagate. When a function runs (the function node uses ExpressionEvaluator::evaluateOrThrow), a failing or asserting expression throws — it surfaces to the engine so it isn't silently masked, and a wrapping Transaction can roll back. (The swallowing evaluate() variant — returns null and logs on error — is still used for config interpolation, not for running functions.) To make a multi-write function atomic, run it inside a transaction node; to abort it on a business rule, use util_assert.
callable runtime
body is a key into the FunctionRegistry. You register a PHP callable at boot (see Extending):
app(\Andre\AiPageBuilder\Flow\FunctionRegistry::class)
->register('calc-total', fn (array $args, \Andre\AiPageBuilder\Flow\FlowContext $ctx): float
=> (float) $args['price'] * 1.2);The callable signature is callable(array $args, FlowContext $ctx): mixed.
php runtime — and the security flag
body is raw PHP executed in a closure with $args, $input, $vars, $states, $globals in scope. Inside it you can read/write States, query collections, etc.:
$total = 0;
foreach ($args['items'] as $item) {
$total += $item['price'];
}
app(\Andre\AiPageBuilder\Services\Data\VariableStore::class)->set('cart_total', $total);
return $total;Security:
phpis OFF by default. Thephpruntime runs arbitrary PHP, so it is gated byflow.allow_php_functions(AI_PAGE_BUILDER_ALLOW_PHP, defaultfalse). With it off, thephpoption is hidden from the Function form and anyphpfunction is refused at run time (returnsnull). The recommended power model is eval-free: anexpressionfunction composing the helper library covers data writes, UI feedback, auth and utilities without arbitrary code. Only opt in tophp(AI_PAGE_BUILDER_ALLOW_PHP=true) on a single-tenant, self-hosted install where the function author is the app owner — never when less-trusted authors can edit functions.
The helper library
The expression sandbox ships with a curated, eval-free set of callables — the helper library — so a Function can read and write data, give UI feedback, read the user, and shape values without raw PHP. They are grouped by category and surface in the function editor's ƒ Insert helper… dropdown (each entry shows its description and usage), and they're the same callables an MCP/AI layer sees in the capability catalogue. Call a helper by its name inside any expression function.
| Helper | Category | Description | Usage |
|---|---|---|---|
db_create |
Data | Create a record in a collection; returns the created row. | db_create('orders', {customer_id: 1, total: 99.5}) |
db_update |
Data | Update a record by id; returns the updated row (or null if missing). | db_update('orders', 42, {status: 'paid'}) |
db_delete |
Data | Delete a record by id; returns true on success. | db_delete('cart_items', 7) |
db_find |
Data | Fetch a single record by id; returns the row or null. | db_find('products', input.product_id) |
db_list |
Data | List records with optional Directus-style params (filter/sort/search/per_page); returns rows. | db_list('orders', {filter: {status: {eq: 'open'}}, sort: '-created_at'}) |
db_aggregate |
Data | Aggregate a column (sum/count/avg/min/max), optionally grouped; returns {metric, total, rows}. |
db_aggregate('order_items', {metric: 'sum', field: 'subtotal', filter: {order_id: {eq: 42}}}) |
ui_notify |
UI & Feedback | Show a toast notification. | ui_notify('Order placed!', 'success') |
ui_alert |
UI & Feedback | Show a blocking alert dialog with a title and message. | ui_alert('Out of stock', 'This item just sold out.') |
ui_modal |
UI & Feedback | Open or close a modal by selector; optionally set its inner HTML. | ui_modal('open', '#checkout-modal') |
ui_redirect |
UI & Feedback | Navigate the browser to a URL. | ui_redirect('/p/order-confirmation') |
ui_logout |
Auth & Access | Sign the current end-user out and redirect to login. | ui_logout() |
ui_set_state |
UI & Feedback | Push a value into the reactive store ($store.app) so bound components re-render. |
ui_set_state('cart_total', vars.total) |
auth_user |
Auth & Access | The signed-in end-user as an array (sensitive columns hidden), or null when a guest. | auth_user() |
auth_id |
Auth & Access | The signed-in end-user id, or null when a guest. | auth_id() |
auth_check |
Auth & Access | True when an end-user is signed in. | auth_check() |
util_now |
Utilities | The current date/time with a PHP date format (default Y-m-d H:i:s). |
util_now('Y-m-d') |
util_uuid |
Utilities | A new random UUID v4 string. | util_uuid() |
util_number_format |
Utilities | Format a number with a fixed number of decimals. | util_number_format(vars.total, 2) |
util_json_encode |
Utilities | Encode a value to a JSON string. | util_json_encode(vars.payload) |
util_json_decode |
Utilities | Decode a JSON string to a value. | util_json_decode(input.body) |
util_assert |
Utilities | Enforce a precondition — if the condition is falsey, fail the step with the given message. | util_assert(db_find('products', vars.item.id)['stock'] >= vars.item.qty, 'Insufficient stock') |
Notes:
db_*route through the sameRecordQuerylayer the Record node and REST API use. They are author-trusted (full data access, like the Record node); the REST API stays the permission-enforced edge. Wrap writing helpers in a Transaction for all-or-nothing behaviour.ui_*queue a browser action onto the running flow (the same channel the Result node uses, viaFlowRuntime). Outside a flow run they are graceful no-ops.util_assertis the business-rule guard: a falsey condition (false/null/0/''/[]) throws with your message. Because function errors propagate, that aborts the step — and inside atransactionit rolls everything back. It is the linchpin of the eval-free atomic checkout.
To add your own helper, see Extending flow nodes & helpers — registered helpers appear in the dropdown and the catalogue automatically.
States
A State is a persistent, app-wide variable in page_builder_variables (src/Models/Variable.php). The Filament resource is labelled States; the model is Variable. Route key key; the VariableStore service is the read/write API.
| Column | Notes |
|---|---|
key |
^[a-z][a-z0-9_]*$. Immutable after create. |
type |
string | number | boolean | json |
value |
Stored as a string; cast back on read per type |
description |
Optional |
is_protected |
Guards against casual edit/delete (advisory) |
Type round-tripping: number → int/float, boolean → 1/0 ↔ bool, json → json_encode/json_decode, string → as-is.
Reading & writing
VariableStore (src/Services/Data/VariableStore.php) — a memoized singleton (the full map is loaded once per request and the cache is flushed on every write):
$store = app(\Andre\AiPageBuilder\Services\Data\VariableStore::class);
$store->get('tax_rate', 0.0); // typed value, with default
$store->set('tax_rate', 0.2); // infers type when omitted
$store->set('flags', ['beta' => true], 'json');
$store->has('tax_rate');
$store->forget('tax_rate');
$store->all(); // key => typed valueFrom flows: the set_variable node writes a State; any {{ states.x }} token reads one. From expression functions: state('x').
The reactive Store (data binding on pages)
When a page renders, all States are serialized and seeded into an Alpine store named app:
window.__pbState = { /* every State, key => value */ };
Alpine.store('app', { ...window.__pbState });Page markup binds to it declaratively over $store.app.<key> — x-text, x-show, x-model, x-for (see Pages → declarative binding). The current end-user is added at $store.app.$user (from GET /pb-auth/me).
A flow can update the store live without a reload: a result action of type setState/setStates (applied by the page runtime) writes into $store.app, and every bound component re-renders. (Persisting the value server-side is a separate set_variable node — setState only changes the in-page store for that visitor.)
Next: Authentication & permissions.