nxp/math-executor
Safely parse and evaluate math expressions from strings in PHP. nxp/math-executor is a lightweight library with variables, functions, custom operators, and caching, ideal for calculators, dynamic pricing, and formula-driven logic in Laravel and PHP apps.
Install
composer require nxp/math-executor
Pin the version you test against — composer require nxp/math-executor:^2.4. Between 2.3 and 2.4 the type of exception thrown for bad values changed (see Gotchas); you want upgrades to be deliberate, not incidental.
No service provider, no config file, no migrations. It is a plain library you instantiate yourself — which means the integration work is yours.
Where to look first
vendor/nxp/math-executor/src/MathExecutor.php — the public API (execute, setVar, setVars, addFunction, addOperator, setCache, setDivisionByZeroException). Skim this before writing any wrapper; it is a few hundred lines.vendor/nxp/math-executor/tests/ — the fastest way to learn actual behaviour for edge cases: precedence, unary minus, bracket errors, unknown variables, function arity, and (as of 2.4.0) which exception class surfaces for invalid values. Better than the docs.src/Exceptions/ — read this tree before writing any catch blocks. Since 2.4.0 the library normalises value/arithmetic failures into its own exception hierarchy instead of letting native PHP errors escape, so this directory is the authoritative list of what execute() can throw.First use case (the one you'll actually have)
A discount/pricing formula that a product manager wants to edit without a deploy:
use MathExecutor\MathExecutor;
$executor = new MathExecutor();
$executor->setVars([
'price' => 149.99,
'qty' => 3,
'discount' => 0.10,
]);
$total = $executor->execute('round(price * qty * (1 - discount), 2)');
// 404.97
Sanity-check it in php artisan tinker before wiring anything else up. Then immediately try a broken one:
$executor->execute('price / 0'); // what class is this now?
$executor->execute('price * "abc"'); // and this?
On 2.4.0+ both should land in MathExecutor\Exceptions\*. Confirming that on your installed version is the single most useful five minutes you will spend, because it determines how much defensive catch scaffolding you need.
The executor is stateful — variables live on the instance. Under Octane (or any long-running worker), a singleton will leak one user's variables into the next request. Use bind or Laravel 11's scoped:
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->scoped(MathExecutor::class, function () {
$executor = new MathExecutor();
// Register only what business formulas legitimately need.
foreach (config('formulas.functions') as $name => $callable) {
$executor->addFunction($name, $callable);
}
return $executor;
});
}
Then wrap it in a domain service so controllers/jobs never touch the raw executor:
namespace App\Services;
use MathExecutor\MathExecutor;
use MathExecutor\Exceptions\MathExecutorException;
class FormulaEvaluator
{
public function __construct(private MathExecutor $executor) {}
public function evaluate(string $expression, array $vars = []): float
{
$this->executor->setVars($vars); // overwrite, don't accumulate
try {
return (float) $this->executor->execute($expression);
} catch (MathExecutorException $e) {
// Since 2.4.0 this also covers invalid values in operations,
// not just parse/unknown-symbol errors.
throw new FormulaException($expression, previous: $e);
}
}
}
One catch at this boundary is now sufficient. On 2.3.x and earlier you needed a second catch (\Throwable) to survive native TypeError/DivisionByZeroError; keeping that belt-and-braces block is harmless but it should no longer be load-bearing.
The classic Laravel use case: a pricing_rules or commission_plans table with an expression column. The rule you want is fail on save, not on read — a formula that only throws when a customer checks out is a production incident.
// app/Rules/ValidFormula.php
public function passes($attribute, $value): bool
{
try {
app(MathExecutor::class)->execute($value); // parse-only check
return true;
} catch (\MathExecutor\Exceptions\MathExecutorException $e) {
$this->message = $e->getMessage();
return false;
}
}
Caveat: parse-only validation still requires every variable in the expression to be defined, or you get UnknownVariableException. Seed a probe set of variables (setVars(['price' => 1, 'qty' => 1, ...]) from a declared variable schema column) so validation exercises the real path.
Second caveat, newly relevant: because value errors are now MathExecutorExceptions too, probe values should be numeric and non-zero so you are testing the grammar and not accidentally testing your own placeholder data. Use 1, not null or 0.
Don't let callers pass arbitrary variables via setVars() without a whitelist. Store the expected variable names alongside the formula, then:
$allowed = $rule->variable_schema; // ['price','qty','discount']
$vars = array_intersect_key($context, array_flip($allowed));
// Pre-seed missing keys so formulas referencing an optional var don't explode.
$vars = array_merge(array_fill_keys($allowed, 0), $vars);
$result = $evaluator->evaluate($rule->expression, $vars);
The 0 default deserves a second look on 2.4.0+: a defaulted zero flowing into a division is now a clean, catchable MathExecutorException rather than a raw PHP error mid-loop — good — but it is still a wrong number for a customer. Prefer defaulting to 1 where the variable is a multiplier, and validate types before setVars():
$vars = array_map(fn($v) => is_numeric($v) ? (float) $v : 0.0, $vars);
Coercing at the boundary means the executor only ever sees well-formed numbers, which is cheaper than relying on exception normalisation.
Clamping, tiering, and lookup-style behaviour belong in named functions rather than gnarly nested conditionals:
$executor->addFunction('clamp', fn($v, $min, $max) => max($min, min($max, $v)));
$executor->addFunction('percent', fn($v) => $v / 100); // users expect this; % is modulo
$executor->addFunction('tier', function ($units) {
return match (true) {
$units >= 1000 => 0.85,
$units >= 100 => 0.92,
default => 1.0,
};
});
Then a PM can write price * qty * tier(qty) * (1 - percent(discount)).
Keep every registered function pure and deterministic. No DB calls, no now(), no random_int(). If a formula's output depends on side effects, you cannot unit-test it, cannot cache around it, and cannot explain a diff to finance.
Throw library exceptions from your callbacks. If a custom function can receive nonsense (a negative term, an out-of-range band), throw a MathExecutorException subclass rather than letting a \ValueError or \InvalidArgumentException escape. Depending on version, an escaping native exception may be wrapped at the executor boundary — but relying on that is fragile, and the wrapped message will not name your function. Throwing the library's own type keeps the error contract identical to built-ins.
Repeatedly parsing the same string on every invoice line is wasted work. The executor accepts a PSR-16 CacheInterface. Laravel's cache repository is not PSR-16, so bridge it with symfony/cache or a ~30-line adapter:
use Symfony\Component\Cache\Adapter\Psr16Adapter;
use Symfony\Component\Cache\Psr16Cache;
$executor->setCache(new Psr16Cache(
new Psr16Adapter('formulas', new \Symfony\Component\Cache\Adapter\ArrayAdapter())
));
Note what's cached: the parsed expression tree, keyed by expression string — not the result. That makes caching safe even when variables change per row. The cache is a per-request/per-worker optimisation; back it with the array adapter unless you have profiling evidence that parse cost matters across requests.
When evaluating a formula across 10,000 rows (repricing job, commission run), reuse one executor and reset variables each iteration:
$executor = app(MathExecutor::class);
$executor->setVars($baseVars); // shared constants once
foreach ($rows->lazy(500) as $row) {
$executor->setVars(array_merge($baseVars, $row->toFormulaVars()));
$results[$row->id] = $executor->execute($formula);
}
If the executor accumulates rather than replaces on setVars, create a fresh instance per batch or explicitly clear variables — verify this against your installed version's tests.
Batch runs are where the 2.4.0 change pays off most. Previously a single bad row could abort a chunk with a native PHP error that your catch (MathExecutorException) never saw, leaving the job half-written. Now a per-row try/catch (MathExecutorException) collects failures and lets the run finish:
foreach ($rows->lazy(500) as $row) {
try {
$results[$row->id] = $executor->execute($formula);
} catch (MathExecutorException $e) {
$results[$row->id] = null; // log, report, retry later
}
}
Formulas with an empty toolbox generate support tickets. The executor exposes its supported functions/operators (check getSupportedFunctions() / getSupportedOperators() in your version) — feed those into the formula editor's autocomplete alongside your custom functions and the rule's variable schema.
Upgrading to 2.4.0 — audit your catch blocks. The changelog is one line: invalid values in math operations used to surface as native PHP exceptions and now throw MathExecutorExceptions. That is a strict improvement, but it is a behaviour change you can observe. Grep your codebase (and your tests) for catch (TypeError, catch (DivisionByZeroError, catch (ArithmeticError, catch (ValueError, and any instanceof checks on those around execute(). Those branches are now dead, and a test asserting expectException(DivisionByZeroError::class) will fail. Replace them with MathExecutorException and — if anything downstream branches on why it failed — inspect the concrete subclass rather than parsing messages.
It's a calculator, not a ledger. Everything runs through native PHP int|float. 0.1 + 0.2 problems are yours to solve: wrap results in round(..., 2) inside the expression, or do money in integer cents and convert at the boundary. Never compare formula output with ===; use an epsilon.
Division by zero is configurable — configure it. setDivisionByZeroException() exists; set the behaviour explicitly rather than relying on whatever the default is in the version you installed, and test it. Post-2.4.0 the thrown type is a library exception, which makes the "throw" mode easier to handle — but returning 0 silently is still how a zero-denominator pricing bug ships to production.
"Caught" is not "handled". Because 2.4.0 funnels a wider range of failures through one base class, a single catch (MathExecutorException) can now hide a division-by-zero and a genuinely malformed formula behind the same fallback. Distinguish on the exception subclass where it matters, and never let the catch-all silently return 0 for a money path.
State leakage is the #1 bug in this integration. A container singleton plus setVars() from a previous request equals wrong numbers for someone else. Use scoped/bind, or a factory that returns a fresh new MathExecutor() per evaluation. Add a test that evaluates two different formulas with different vars in sequence and asserts no bleed-through.
Unknown variables and unknown functions throw. UnknownVariableException / UnknownFunctionException (both extend MathExecutorException). Pre-seed every declared variable with a default to avoid surprise exceptions, and catch the base exception at your service boundary, mapping it to a ValidationException for user-facing surfaces. After upgrading, confirm the base class is genuinely the common ancestor of everything you catch — the 2.4.0 normalisation is precisely what makes one catch viable, but check it in src/Exceptions/ rather than assuming.
It is safe from eval, but not from you. Expression strings are tokenised and parsed — no PHP code execution, so a malicious formula can't call system(). The risk arrives when you register functions. Never register anything that touches the filesystem, network, container, or DB. Also guard against resource exhaustion: cap expression length in a validation rule, cap parenthesis nesting, and watch exponents (9^9^9 in a user-supplied formula will hurt). Rate-limit or queue any endpoint that evaluates untrusted formulas. Note that 2.4.0 improves reporting of bad values, not containment — a huge-but-valid exponent still runs to completion.
No implicit multiplication. 2(3+4) is a parse error; users typing from a maths background will do this. Either document 2 * (3 + 4) prominently or normalise the input before saving. Same for thousands separators — 1,000 will break, so strip them at the input layer.
% is modulo, not percent. Users will assume otherwise. Register a percent() function (see above) and mention it in the editor.
^ associativity and unary minus precedence may surprise Excel-trained users. -2^2 and 2^3^2 are classic disputes. Pin behaviour with tests before PMs start arguing about a 4-cent discrepancy.
Case sensitivity. Function and variable names are matched as given. Decide on a convention (lowercase variables, snake_case) and normalise on save, otherwise Price and price become two different things and only one of them is defined.
Function arity is enforced. A custom function declared with two parameters called with one argument throws. Use variadic or defaulted parameters where the formula language should be forgiving.
Pin the minor version too, at least at first. This is a small, single-maintainer library with a modest dependent count, and 2.4.0 demonstrates that a patch-sized changelog line can change exception semantics. Use ^2.4 in composer.json, read the release notes before bumping, and re-run your failure-path tests after every upgrade. Wrap it behind your own FormulaEvaluator interface so replacing the engine (or moving to a more full-featured expression library) is a one-file change rather than a codebase-wide search.
Extension points, in order of cost/benefit: custom functions → custom operators (new Operator(...), check the constructor's argument order in your version) → swapping the tokenizer/parser. Stop at level two unless you genuinely need a new grammar; levels one and two cover nearly every business-rule formula.
Test the failure path — this is now the highest-value test you own. For every stored formula feature, have a test asserting that a syntactically broken formula is rejected at save time with a readable message, and a test that an invalid-value evaluation (divide by zero, absurd input) surfaces as a MathExecutorException rather than a native PHP error. The second test is the one that would have caught the 2.4.0 change on your behalf, and it is the one that keeps your catch blocks honest on the next upgrade.
How can I help you explore Laravel packages today?