I've been working on Cockpit, a headless CMS, for a while now. One thing that kept coming up was the need for user-defined logic — computed fields, validation rules, content transformations, stuff like that. The kind of thing where you want your CMS users to write small snippets of logic without giving them the keys to the entire PHP runtime.
I looked at existing options. V8js is heavy and a pain to deploy. Lua doesn't feel right for a web-focused CMS where most users already know JavaScript. Expression languages are too limited once you need a loop or a callback. So I started building my own.
What began as a simple expression evaluator for Cockpit turned into a full ECMAScript subset interpreter: ScriptLite.
What it does
It runs JavaScript (ES5/ES6 subset) inside PHP. No filesystem access, no network, no eval, no require — scripts can only touch the data you explicitly pass in. Think of it as a sandbox where users write logic and you control exactly what they can see and do.
$engine = new ScriptLite\Engine();
// User-defined pricing rule stored in your database
$rule = '
let total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
if (total > 100) total *= (1 - discount);
Math.round(total * 100) / 100;
';
$result = $engine->eval($rule, [
'items' => [
['price' => 29.99, 'qty' => 2],
['price' => 49.99, 'qty' => 1],
],
'discount' => 0.1,
]);
// $result === 98.97
It supports the stuff people actually use day to day: arrow functions, destructuring, template literals, spread/rest, array methods (map, filter, reduce, ...), object methods, regex, try/catch, Math, JSON, Date, and more.
PHP interop
You can pass in PHP objects directly. Scripts can read properties, call methods, and mutations flow back to your PHP side:
$order = new Order(id: 42, status: 'pending');
$engine->eval('
if (order.total() > 500) {
order.applyDiscount(10);
order.setStatus("vip");
}
', ['order' => $order]);
// $order->status is now "vip"
You can also pass PHP closures as callable functions, so you control exactly what capabilities the script has:
$engine->eval('
let users = fetchUsers();
let active = users.filter(u => u.lastLogin > cutoff);
active.map(u => u.email);
', [
'fetchUsers' => fn() => $userRepository->findAll(),
'cutoff' => strtotime('-30 days'),
]);
Three execution backends
This is the part that got a bit out of hand. I ended up building three backends:
Bytecode VM — compiles to bytecode, runs on a stack-based VM in pure PHP. Works everywhere, no dependencies.
PHP transpiler — translates the JavaScript to PHP source code that OPcache/JIT can optimize. About 40x faster than the VM. Good for hot paths.
C extension — a native bytecode VM with computed-goto dispatch. About 180x faster than the PHP VM. Because at some point I thought "how fast can this actually go" and couldn't stop.
The nice thing is that the API is the same regardless of backend. The engine picks the fastest available one automatically:
$engine = new Engine(); // uses C ext if loaded, else PHP VM
$engine = new Engine(false); // force pure PHP
// Same code, same results, different speed
$result = $engine->eval('items.filter(x => x > 3)', ['items' => [1, 2, 3, 4, 5]]);
The transpiler path is interesting if you want near-native speed without a C extension:
// Transpile once, run many times with different data
$callback = $engine->getTranspiledCallback($script, ['data', 'config']);
$result = $callback(['data' => $batch1, 'config' => $cfg]);
$result = $callback(['data' => $batch2, 'config' => $cfg]);
Possible use cases
-
User-defined formulas — let users write
price * quantity * (1 - discount)in a CMS, form builder, or spreadsheet-like app -
Validation rules — store rules like
value.length > 0 && value.length <= 280in your database and evaluate them at runtime - Computed fields — derive a field's value from other fields using a JS expression
- Content transformation — map, filter, reshape API payloads or database rows with user-supplied logic
- Workflow / automation rules — evaluate conditions and trigger actions defined by end users
- Feature flags & A/B rules — express targeting logic as scripts instead of hardcoded PHP
-
Conditional UI — show/hide elements based on expressions like
status === "draft" && role === "editor"
It's a standalone library with no framework dependency. composer require aheinze/scriptlite and you're good.
Some numbers
Benchmarked on PHP 8.4 with 10 different workloads (fibonacci, quicksort, sieve, closures, tree traversal, matrix math, etc.):
| Backend | Total time | vs PHP VM |
|---|---|---|
| PHP VM | 2608 ms | 1x |
| Transpiler | 66 ms | 40x faster |
| C Extension | 14.6 ms | 178x faster |
The transpiler gets within 3-4x of native PHP, which is honestly good enough for most use cases. The C extension is there for when you want to go full send.
Install
composer require aheinze/scriptlite
For the C extension:
pie install aheinze/scriptlite-ext
Repo: https://github.com/aheinze/ScriptLite
909 tests across all backends. MIT licensed. PHP 8.3+.
Would love to hear what you think, especially if you've run into similar "I need users to write logic but not PHP" situations. What did you end up doing?
Top comments (0)