Skip to content

shyim/php-lua

Repository files navigation

php-lua

A native, pure-PHP interpreter for Lua 5.4 - a tree-walking interpreter with no compilation step, no FFI, and no C extensions. Embed a sandboxed Lua scripting engine in any PHP 8.3+ application.

CI PHP License

  • Pure PHP - runs anywhere PHP 8.3+ runs; nothing to compile or install beyond Composer.
  • Lua 5.4 - integer/float subtypes, bitwise operators, integer division, goto, <const>/<close> attributes, metatables, coroutine-free standard library (string with full Lua patterns, table, math, utf8, plus os/io in trusted mode).
  • Safe by default - new Lua() is sandboxed: no filesystem, no os/io, and hard resource limits (instruction budget, call depth, wall-clock timeout, output and allocation caps), all surfaced as catchable errors. Built for running untrusted, user-supplied Lua.
  • First-class embedding - register PHP callables as Lua functions and call Lua from PHP, with automatic value marshalling.
  • Conformance-tested - a differential test suite runs the corpus against the reference Lua 5.4 interpreter and asserts byte-for-byte identical behavior.

Installation

composer require shyim/lua

Requires PHP 8.3+. No extensions required.

Quick start

use PhpLua\Lua;

$lua = new Lua();                       // sandboxed, safe for untrusted input

[$sum] = $lua->eval('local s = 0; for i = 1, 10 do s = s + i end; return s');
echo $sum;                              // 55

eval() returns the chunk's return values as a PHP list. Lua values map to PHP as: nilnull, booleanbool, integerint, floatfloat, stringstring, tablePhpLua\Runtime\LuaTable.

Command-line runner

vendor/bin/php-lua script.lua          # run a file
vendor/bin/php-lua -e 'print(2^10)'    # run an inline snippet
echo 'print("hi")' | vendor/bin/php-lua # read from stdin

The CLI runs in trusted mode (full standard library, no limits) - it is a developer tool. Untrusted input should go through the sandboxed library API, not the CLI.

Sandboxing untrusted scripts

A plain new Lua() uses the Sandboxed profile and the default resource limits. It does not expose os or io (no filesystem, no os.exit, no environment), and it stops runaway scripts with catchable LuaErrors rather than letting them hang or OOM the host.

use PhpLua\Lua;
use PhpLua\Runtime\LuaError;
use PhpLua\Sandbox\Limits;
use PhpLua\Sandbox\Profile;

// Sandboxed by default. os/io/load are nil; string/table/math/utf8 are available.
$lua = new Lua();

try {
    $lua->eval('while true do end');                 // never returns in real Lua...
} catch (LuaError $e) {
    echo $e->getMessage();                           // input:1: instruction limit exceeded
}

Tuning the limits

$limits = new Limits(
    instructionLimit: 1_000_000,   // statements executed before aborting (0 = unlimited)
    callDepthLimit:   200,         // max Lua call nesting -> "stack overflow"
    timeoutMs:        2_000,       // wall-clock budget in milliseconds
    outputLimitBytes: 8 * 1024 * 1024,
    maxStringBytes:   64 * 1024 * 1024,  // caps a single string allocation (rep/concat/format)
    maxResultCount:   1_000_000,   // caps multi-return producers (unpack/string.byte/host calls)
    maxBuiltinIterations: 1_000_000, // caps one stdlib operation's loop range
    maxSourceBytes:   1024 * 1024, // caps source size before lexing/parsing
    maxTokens:        200_000,     // caps tokens before parsing
    maxAstNodes:      200_000,     // caps parsed AST size
    maxParserDepth:   512,         // caps recursive parse nesting
);

$lua = new Lua(Profile::Sandboxed, $limits);

Every limit fires as a catchable LuaError (so a script's own pcall or your host code can catch it), never an uncatchable PHP fatal. A hard limit also latches, so a hostile pcall loop cannot swallow it and keep running.

Trusted mode

For the host's own scripts that need the full standard library and no budget:

$lua = Lua::trusted();   // Profile::Trusted + Limits::unlimited(): os, io, no limits

Do not run untrusted input in trusted mode - it exposes the filesystem, process control, and the environment.

Coroutines are not supported

There is no coroutine library (coroutine.create, wrap, yield, resume, …), and this is a deliberate decision rather than a missing feature.

In a tree-walking interpreter, a Lua call frame is a PHP call frame, so suspending a coroutine means suspending the PHP stack - which requires either PHP Fibers or a continuation-passing rewrite of the evaluator. Both fight directly with the sandbox: the resource limits (instruction budget, wall-clock deadline, call-depth cap, the latch that stops a pcall loop from swallowing a limit) are all accounted against a single, linear execution. Coroutines introduce multiple suspended stacks that can be resumed arbitrarily, which makes "how much has this script spent, and can it be interrupted safely?" much harder to answer - exactly the guarantee this engine exists to provide for untrusted code.

Rather than ship coroutines with weaker sandbox guarantees, we omit them. If you need cooperative scheduling, model it on the host side (expose a PHP-driven step/yield API via the embedding API) instead of from inside the sandbox.

Embedding: PHP ↔ Lua interop

Expose PHP to Lua

$lua = new Lua();

// A single function...
$lua->register('greet', fn (string $name): string => "Hello, $name!");
[$msg] = $lua->eval('return greet("world")');        // "Hello, world!"

// ...or a whole namespace table.
$lua->registerTable('clock', [
    'now' => fn (): int => time(),
    'tz'  => 'UTC',
]);
$lua->eval('print(clock.now(), clock.tz)');

A registered PHP callable receives its arguments marshalled to natural PHP values and its return value is marshalled back to Lua. Security note: registered functions run with the host's authority - exposing one that touches the filesystem re-introduces that capability to the script. The Lua timeout/instruction budget cannot preempt PHP code while it is inside your callback, so keep callbacks short, bounded, and side-effect-limited. Non-LuaError exceptions thrown by callbacks are reported to Lua as a generic host function failed error to avoid leaking host paths or diagnostics; throw LuaError yourself only when you intend the Lua script to see that error value/message.

Call Lua from PHP

$lua->eval('function add(a, b) return a + b end');

$lua->call('add', 3, 4);          // 7  (first return value, marshalled to PHP)
$lua->callMulti('add', 3, 4);     // [7] (all return values)

$lua->setGlobal('config', ['retries' => 3, 'name' => 'svc']);  // PHP array -> Lua table
$lua->getGlobal('config');        // marshalled back to a PHP array

Structured errors

try {
    $lua->eval('error({ code = 42, msg = "nope" })');
} catch (LuaError $e) {
    $value = $e->getValue();      // the raw Lua error object (here a LuaTable)
    // $e->toPhpValue($lua->getInterpreter()) marshals it to a PHP array
}

Modules (require)

The module system is off by default - with no loader, require is not even registered (the safe default for a sandbox). Attach a module loader to enable it. Loaders are an abstraction (PhpLua\Module\ModuleLoader): require resolves module source through the loader and never touches the real filesystem unless the loader you provide does.

use PhpLua\Lua;
use PhpLua\Module\ArrayModuleLoader;

$lua = new Lua();
$lua->setModuleLoader(new ArrayModuleLoader([
    'greeter' => 'local M = {}; function M.hello(w) return "hi " .. w end; return M',
    'config'  => 'return { retries = 3 }',
]));

[$msg] = $lua->eval('local g = require("greeter"); return g.hello("lua")');  // "hi lua"

A module body runs once (the result is cached in package.loaded), receives its own name as ..., and - crucially for a sandbox - runs under the same resource budget as the script that required it. A script therefore cannot use require (or a module that loops forever) to escape the instruction/time limits.

Loaders

Loader Source
ArrayModuleLoader([$name => $src]) In-memory virtual filesystem (the safe default).
ChainModuleLoader($a, $b, ...) Tries each loader in order; first hit wins.
FilesystemModuleLoader($baseDir) Real files (foo.bar$baseDir/foo/bar.lua). Never auto-wired into the sandbox; confined to $baseDir (rejects .., absolute paths, and symlink escapes). Opt in only for trusted module roots.

Implement ModuleLoader::resolve(string $name): ?string for any custom source (a database, a CMS, an HTTP cache). Returning null makes require report module 'name' not found. Custom loaders also run with host authority while resolving source, so use bounded, trusted loaders for untrusted Lua. Calling setModuleLoader(null) disables require, removes the package table, and clears module cache/preload state from Lua; re-enabling a loader creates a fresh package table.

Public API

Method Description
new Lua(Profile $p = Sandboxed, ?Limits $l = null) Construct an interpreter.
Lua::trusted(): self Full stdlib, no limits - for trusted host scripts.
eval(string $code, string $chunkName = 'input'): array Run a chunk; returns its values.
doFile(string $path): array Run a host-selected .lua file (skips a #! shebang); do not pass untrusted paths.
register(string $name, callable $fn): void Expose a PHP callable as a Lua global.
registerTable(string $name, array $members): void Expose a namespace table.
setGlobal(string $name, mixed $v): void / getGlobal(string $name): mixed Exchange globals.
call(string $name, mixed ...$args): mixed Invoke a Lua function from PHP (first return).
callMulti(string $name, mixed ...$args): array Invoke a Lua function (all returns).
setModuleLoader(?ModuleLoader $l): self Enable require/package with a module loader.
getGlobals(): LuaTable / getInterpreter(): Interpreter Lower-level access.
getLimits(): Limits The resource limits in force.

getGlobals() and getInterpreter() are intentionally low-level escape hatches for embedders. Do not hand the same Lua instance to mutually untrusted tenants: globals, loaded modules, and host-registered values are persistent interpreter state. Create one sandbox per trust domain.

Development

composer install

composer test        # PHPUnit (unit + integration + differential suites)
composer analyse     # PHPStan (level 6)
composer cs-check    # PHP-CS-Fixer (dry-run)
composer cs-fix      # PHP-CS-Fixer (apply)
composer bench       # PhpBench microbenchmarks
composer qa          # cs-check + analyse + test

Differential testing

The tests/Diff suite runs a corpus of Lua programs through both this interpreter and the reference Lua 5.4 interpreter and asserts identical output. It is skipped automatically when no reference Lua 5.4 is installed (so it never blocks CI without it).

To run it locally, install reference Lua 5.4 and point the suite at it:

brew install lua@5.4                                   # macOS
export LUA54=/opt/homebrew/opt/lua@5.4/bin/lua5.4      # or any lua5.4 binary
composer test
php tests/Diff/run-diff.php                            # standalone report

Performance

This is a tree-walking interpreter - fast to embed, not a JIT. Indicative figures (composer bench, hardware-dependent): fib(22) ≈ 175 ms, a 1M-iteration arithmetic loop ≈ 1.2 s. Control flow uses sentinel returns (not exceptions), AST dispatch is a kind-indexed jump table, and locals are slot-resolved at parse time.

License

MIT

About

A native, pure-PHP tree-walking interpreter for Lua 5.4 - no compilation, no FFI, no extensions.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors