DEV Community

Artur
Artur

Posted on

ScriptLite — a sandboxed ECMAScript subset interpreter for PHP (with optional C extension)

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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'),
]);
Enter fullscreen mode Exit fullscreen mode

Three execution backends

This is the part that got a bit out of hand. I ended up building three backends:

  1. Bytecode VM — compiles to bytecode, runs on a stack-based VM in pure PHP. Works everywhere, no dependencies.

  2. PHP transpiler — translates the JavaScript to PHP source code that OPcache/JIT can optimize. About 40x faster than the VM. Good for hot paths.

  3. 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]]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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 <= 280 in 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
Enter fullscreen mode Exit fullscreen mode

For the C extension:

pie install aheinze/scriptlite-ext
Enter fullscreen mode Exit fullscreen mode

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)