A lightweight formula engine for PHP built on an Abstract Syntax Tree (AST). Parse, evaluate, and transform expressions with support for variables, functions, wildcards, and operator precedence.
- PHP 8.4+
- ext-ctype
- ext-mbstring
composer require oxygensuite/php-astuse OxygenSuite\PhpAst\Formulas\FormulaEvaluator;
$evaluator = new FormulaEvaluator();
$data = [
'lines' => [
['quantity' => 2, 'unit_price' => 10.0, 'discount' => 0],
['quantity' => 3, 'unit_price' => 15.0, 'discount' => 5],
['quantity' => 1, 'unit_price' => 8.0, 'discount' => 0],
],
];
// Variable access (dot-notation)
$evaluator->evaluate('lines.0.quantity', $data); // 2
// Arithmetic
$evaluator->evaluate('lines.0.quantity * lines.0.unit_price', $data); // 20.0
// Wildcards
$evaluator->evaluate('SUM(lines.*.quantity)', $data); // 6
// Functions
$evaluator->evaluate('MAP(lines; quantity * unit_price - discount)', $data); // [20, 40, 8]Variables use dot-notation for nested property access. Types are preserved (string, int, float, bool, null, array).
| Syntax | Resolves to |
|---|---|
uid |
$data['uid'] |
lines.0.quantity |
$data['lines'][0]['quantity'] |
a.b.c |
$data['a']['b']['c'] |
Variables can be used directly in expressions:
$evaluator->evaluate('lines.0.quantity * lines.0.unit_price', $data);
$evaluator->evaluate('(base_price + tax) * quantity', $data);The * wildcard extracts values across all items in an array.
// Single-level: lines.*.quantity → PLUCK(lines; "quantity")
$evaluator->evaluate('lines.*.quantity', $data); // [2, 3, 1]
// Multi-level: lines.*.taxes.*.amount → extracts all tax amounts from all lines
$evaluator->evaluate('SUM(lines.*.taxes.*.amount)', $data);Listed from highest to lowest precedence:
| Precedence | Operators | Description |
|---|---|---|
| 1 | ** |
Exponentiation |
| 2 | * / % |
Multiplication, division, modulo |
| 3 | + - |
Addition, subtraction; + joins text |
| 4 | == != < > <= >= <=> |
Comparison |
| 5 | && |
Logical AND |
| 6 | || |
Logical OR |
| 7 | ?? ?: |
Null coalescing, elvis |
Parentheses override precedence: (a + b) * c.
Variables keep their values' types, so text compares and combines as text: customer.name == "ACME",
street || city. + adds numbers, including a number written as text when the other side is a number ("1" + 5 is
6), and joins text otherwise, so codes keep their digits ("001" + "12" is "00112"). &&, ||
and ?: read a value as true or false the way IF() does: text is false only when it is "", "0" or "false".
All functions use semicolons (;) as argument separators.
| Function | Description | Example |
|---|---|---|
SUM(values) |
Sum of numeric values | SUM(lines.*.quantity) |
AVG(values) |
Average | AVG(lines.*.unit_price) |
MIN(values) |
Minimum value | MIN(lines.*.unit_price) |
MAX(values) |
Maximum value | MAX(lines.*.quantity) |
COUNT(values) |
Number of elements, 1 for a single value, 0 for a missing one | COUNT(lines.*.quantity) |
| Function | Description | Example |
|---|---|---|
ROUND(value; precision) |
Round to precision decimals | ROUND(10.567; 2) → 10.57 |
CEIL(value) |
Round up | CEIL(10.1) → 11 |
FLOOR(value) |
Round down | FLOOR(10.9) → 10 |
| Function | Description | Example |
|---|---|---|
PLUCK(array; "key") |
Extract column from array of objects | PLUCK(lines; "quantity") |
FLAT(array; depth) |
Flatten nested arrays (default depth: 1) | FLAT([[1,2],[3,[4,5]]]; 2) |
MAP(array; expr) |
Apply expression per item | MAP(lines; quantity * unit_price) |
FILTER(array; condition) |
Filter items by condition | FILTER(lines; unit_price > 10) |
GROUP(array; "key") |
Group items by key | GROUP(lines; "tax_category") |
SORT(array) |
Sort array | SORT(lines.*.quantity) |
JOIN(array; separator) |
Join into string | JOIN(lines.*.quantity; ",") |
CONCAT(value; …) |
Join values as text, lists item by item | CONCAT(series; "-"; number) |
TEXTJOIN(separator; value; …) |
Join the non-blank values with a separator | TEXTJOIN(" "; street; number) |
FIRST(array) |
First element | FIRST(lines) |
LAST(array) |
Last element | LAST(lines) |
LEN(text) |
Number of characters | LEN(header.invoice_number) |
| Function | Description | Example |
|---|---|---|
IF(cond; true_val; false_val) |
Conditional | IF(quantity > 10; "bulk"; "single") |
VALUE(array; "key") |
Get value by key | VALUE(line; "quantity") |
EMPTY(value) |
Missing, blank or an empty list | EMPTY(customer.name) |
EXISTS(value) |
Not empty: zero counts as a value | EXISTS(header.series) |
Functions can be nested and combined with arithmetic:
// Sum of (quantity * unit_price) per line
$evaluator->evaluate('SUM(MAP(lines; quantity * unit_price))', $data);
// Filter then sum
$evaluator->evaluate('SUM(PLUCK(FILTER(lines; tax_category == 1); "net_amount"))', $data);
// Grouped aggregation
$evaluator->evaluate('GROUP(lines; "tax_category")', $data);
// → [1 => [...], 2 => [...], 3 => [...]]
// Arithmetic on aggregated results
$evaluator->evaluate('SUM(lines.*.net_amount) + SUM(lines.*.vat_amount)', $data);For injecting external values (e.g., configuration, session data) into formula evaluation:
use OxygenSuite\PhpAst\ContextProvider\ContextProvider;
class MyContext implements ContextProvider
{
public function getContextValue(string $key): mixed
{
return match ($key) {
'tax_rate' => 0.24,
'currency' => 'EUR',
default => null,
};
}
}
$evaluator = new FormulaEvaluator(new MyContext());
$evaluator->evaluate('unit_price * tax_rate', $data); // uses tax_rate from contextContext values take priority over data array values.
A context provider that also implements WildcardResolver decides which items a wildcard path reads, for example the
current line of a loop over lines. It gets each wildcard path as the formula writes it, and whether a function reads
it as its list (SUM(lines.*.net_amount), COUNT(lines.*.taxes)), and returns the path to read instead, or null to
read every item:
use OxygenSuite\PhpAst\ContextProvider\ContextProvider;
use OxygenSuite\PhpAst\ContextProvider\WildcardResolver;
class CurrentLine implements ContextProvider, WildcardResolver
{
public function __construct(private int $line) {}
public function getContextValue(string $key): mixed
{
return null;
}
public function resolveWildcardPath(string $path, bool $asList): ?string
{
// The current line's values, except where a function sums or counts all lines
return $asList ? null : preg_replace('/^lines\.\*/', "lines.{$this->line}", $path);
}
}
$evaluator = new FormulaEvaluator(new CurrentLine(2));
$evaluator->evaluate('lines.*.net_amount / SUM(lines.*.net_amount)', $data); // line 2's share of the totalA function's #[FunctionDefinition] says which argument it reads as a list (listArgument), whether only when it is
the only argument (listArgumentAlone, as for SUM, which otherwise adds up its arguments), and whether its result
keeps the keys of that list (keepsKeys, as for FILTER and MAP).
Implement the Formula interface:
use OxygenSuite\PhpAst\AST\ASTEvaluator;
use OxygenSuite\PhpAst\Formulas\Formula;
readonly class DoubleFunction implements Formula
{
public function execute(array $arguments, array $data, ASTEvaluator $evaluator): float|int|string|array
{
return ($arguments[0] ?? 0) * 2;
}
}For functions that need per-item evaluation (like MAP/FILTER), implement ASTFormula instead. It receives raw AST nodes so you can evaluate expressions against each item:
use OxygenSuite\PhpAst\AST\ASTEvaluator;
use OxygenSuite\PhpAst\Formulas\ASTFormula;
readonly class MyTransform implements ASTFormula
{
public function execute(array $astNodes, array $data, ASTEvaluator $evaluator): mixed
{
[$itemsNode, $callbackNode] = $astNodes;
$items = $itemsNode->accept($evaluator, $data);
return array_map(
fn($item) => $callbackNode->accept($evaluator, $item),
$items,
);
}
}Parsed ASTs are automatically cached per evaluator instance (default: 1000 entries). Repeated evaluation of the same formula string skips parsing entirely.
$evaluator->getCacheStats(); // ['hits' => 42, 'misses' => 10, 'size' => 10, 'hitRate' => 0.807]
$evaluator->clearCache();| Scenario | Behavior |
|---|---|
| Missing variable | Returns null |
| Division by zero | Returns 0 |
| Unknown function | Throws ParseException |
| Invalid expression | Throws ParseException |
| Arithmetic on text | Throws RuntimeException |
| Recursion depth > 32 | Throws RuntimeException |
MIT