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.
- 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 (stringwith full Lua patterns,table,math,utf8, plusos/ioin trusted mode). - Safe by default -
new Lua()is sandboxed: no filesystem, noos/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.
composer require shyim/luaRequires PHP 8.3+. No extensions required.
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; // 55eval() returns the chunk's return values as a PHP list. Lua values map to PHP as:
nil↔null, boolean↔bool, integer↔int, float↔float, string↔string,
table↔PhpLua\Runtime\LuaTable.
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 stdinThe 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.
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
}$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.
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 limitsDo not run untrusted input in trusted mode - it exposes the filesystem, process control, and the environment.
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.
$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.
$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 arraytry {
$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
}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.
| 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.
| 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.
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 + testThe 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 reportThis 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.