API
The whole public API is one class: Phasis\Engine. Everything else (JsValue, JsFunction, JsObject, …) is engine-internal and only exposed to host functions that bridge PHP↔JS.
Construction
use Phasis\Engine;
$engine = new Engine(); // lazy mode (default)
$engine = new Engine(eager: true); // install every built-in up frontA fresh Engine exposes the full standard library — Object, Array, String, Number, Symbol, BigInt, Promise, RegExp, Error and the core language intrinsics install eagerly so the spec works (you can't have classes without Function.prototype, async/await without Promise, regex literals without RegExp). Everything else — Math, JSON, Map, Set, Date, TypedArray and the array-buffer family, Atomics, Proxy, Reflect, console, Intl, the weak-ref family, Temporal, ShadowRealm, the Web Platform Pack, and the Fetch Pack — is registered as a placeholder accessor and materializes on first read. Reading the global is transparent: Map, typeof Map, 'Map' in globalThis, Object.keys(globalThis).includes('Map') all work, and after the first read the placeholder is replaced with a regular data descriptor.
Pass eager: true if you specifically need the pre-lazy behavior — every built-in installed at construction with the full descriptor surface visible to introspection. The conformance harness (bin/test262, bin/wpt) constructs engines in eager mode so test262/WPT see the descriptor shapes they expect.
eval
public function eval(string $source, ?string $sourceName = null): mixedParses and executes $source in the engine's global scope. Returns the value of the last evaluated expression (or null if the program had no trailing expression).
$result = $engine->eval('1 + 2'); // 3
$result = $engine->eval('JSON.stringify({a: 1})'); // '{"a":1}'
$result = $engine->eval('[1,2,3].map(x => x * 2)'); // [2, 4, 6] (PHP array)JavaScript exceptions thrown out of eval() surface as PHP Phasis\Exceptions\JsThrowable. The original JsValue is on the exception's $jsValue property.
try {
$engine->eval('throw new Error("boom")');
} catch (\Phasis\Exceptions\JsThrowable $e) {
echo $e->getMessage();
// boom
}execFile
public function execFile(string $path): mixedLoads a file from disk and evaluates it. The path is used as the source name in stack traces. Modules (.mjs or files containing top-level import/export) are loaded with the ES-module loader; everything else is treated as a classic script.
$engine->execFile(__DIR__ . '/script.js');setGlobal
public function setGlobal(string $name, mixed $value): voidBinds a global variable visible to all subsequent JS evaluation. PHP values are converted to JS:
| PHP type | JS type |
|---|---|
null | null |
bool | boolean |
int / float | number |
string | string |
| array (list) | Array |
| array (assoc) | Object |
Closure / callable | function |
| object | object (reference; mutations visible to PHP) |
$engine->setGlobal('config', ['debug' => true, 'version' => '1.0']);
$engine->setGlobal('log', fn(string $msg) => error_log($msg));
$engine->setGlobal('post', $wordPressPost); // passed by referenceInside JS:
console.log(config.version); // "1.0"
log('hello'); // calls the PHP closure
post.title = "Edited"; // mutates the PHP objectcall
public function call(string $name, mixed ...$args): mixedCalls a global JS function with PHP arguments (converted as for setGlobal). The return value is converted back to PHP.
$engine->eval('function add(a, b) { return a + b; }');
echo $engine->call('add', 2, 3); // 5setLimit
public function setLimit(string $limit, int $value): voidRuntime-tunable resource limit. Exceeding it throws Phasis\Exceptions\InternalError from inside JS, which the host can catch as Phasis\Exceptions\JsThrowable.
| Limit | Default | Description |
|---|---|---|
maxLoopIterations | 100000 | maximum iterations per for / while / do…while loop |
$engine->setLimit('maxLoopIterations', 1_000_000);Two additional ceilings are baked into the engine and not runtime-tunable: Phasis\Runtime\CallStack::maxDepth (4 096 JS frames) and the JsToPhp transpiled-closure depth guard (8 192 PHP frames). Both throw "Maximum call stack size exceeded" when crossed. They sit well above any legitimate JS program — bumping them is a code change, not a config knob.
reset
public function reset(): voidClears all user-defined globals and rebuilds the standard library. The engine instance is reusable after reset().
repl
public function repl(): voidDrops into the interactive REPL on stdin/stdout. Used by bin/phasis --repl.
Event loop
The engine ships a JavaScript event loop with setTimeout, setInterval, clearTimeout, clearInterval, and queueMicrotask on the global object. Microtasks (Promise handlers, queueMicrotask) drain between every macrotask; timers fire in deadline order via a per-realm task queue.
public function runEventLoop(): void
public function tickEventLoop(): bool
public function pendingTaskCount(): int
public function getEventLoop(): \Phasis\Runtime\EventLoopeval() and evalAsModule() call runEventLoop() automatically after the synchronous code completes. So a script that schedules setTimeout(cb, 5000) at top level runs cb before eval() returns — the loop uses virtual time, jumping its clock to the next pending deadline rather than sleeping PHP. Matches node script.js semantics.
For real-time embedding (long-lived servers, UI integration, scheduling against a real clock), tickEventLoop() runs one iteration (drain microtasks, fire any due timers) and returns. pendingTaskCount() reports outstanding timers; an embedder can drive its own pump:
while ($engine->pendingTaskCount() > 0) {
$engine->tickEventLoop();
usleep(1000);
}A runaway setInterval is bounded: runEventLoop() caps at 100,000 cycles and throws a RuntimeException — the JS code's fault surfaces as a clear PHP error rather than a hang. Uncaught errors in a timer callback are emitted via error_log() and the loop keeps draining; one bad timer doesn't kill the program.
Fetch hooks
Phasis ships fetch() enabled by default with PHP's ext-curl as the underlying transport. Three hooks let an embedder swap the transport, gate requests, or enable cookie storage. All three accept either a PHP callable or a method-on-object; see Web APIs for the value-shape contract.
setFetchTransport
public function setFetchTransport(callable $transport): voidReplace the default CurlTransport. The callable receives a request descriptor and an optional AbortSignal JsObject; returns a response descriptor.
// Use Guzzle as the transport instead of curl
$client = new GuzzleHttp\Client();
$engine->setFetchTransport(function (array $req, $signal) use ($client) {
$r = $client->request($req['method'], $req['url'], [
'headers' => $req['headers'],
'body' => $req['body'],
'http_errors' => false,
]);
return [
'status' => $r->getStatusCode(),
'statusText' => $r->getReasonPhrase(),
'headers' => array_map(
fn ($name, $values) => array_map(fn ($v) => [$name, $v], $values),
array_keys($r->getHeaders()),
$r->getHeaders(),
),
'body' => (string) $r->getBody(),
];
});A common embedding pattern: install a mock transport in tests, the real curl transport in production.
setFetchPolicy
public function setFetchPolicy(callable $hook): voidPre-flight policy hook. Receives the Request JsObject; can throw to deny, return a modified Request to rewrite, or return null/no value to allow.
// Block non-HTTPS and inject auth on a specific host
$engine->setFetchPolicy(function ($req) {
$url = $req->get('url')->value;
if (str_starts_with($url, 'http://')) {
throw new \RuntimeException('HTTPS only');
}
if (str_contains($url, 'api.example.com')) {
$req->get('headers')->call('set', 'Authorization', 'Bearer ...');
}
return null;
});setCookieJar
public function setCookieJar(mixed $jar): voidOpt-in cookie store. Accepts any object exposing get(string $url): string and set(string $url, string $header): void — PHP-side or JS-side.
class SimpleJar
{
private array $cookies = [];
public function get(string $url): string { return implode('; ', $this->cookies); }
public function set(string $url, string $header): void { $this->cookies[] = $header; }
}
$engine->setCookieJar(new SimpleJar());Without setCookieJar(), fetch ignores both inbound Set-Cookie and outbound Cookie headers — the default is no cookies.
Exceptions
| Class | When |
|---|---|
Phasis\Exceptions\JsThrowable | uncaught JS throw reaches the PHP caller |
Phasis\Exceptions\SyntaxError | parse-time error |
Phasis\Exceptions\RuntimeError | host-side problem (file not found, IO error, etc.) |
Phasis\Exceptions\InternalError | resource limit exceeded |
JsThrowable is the only one your application code is likely to catch deliberately — the rest typically indicate bugs in the script being evaluated or in the host integration.
Lifecycle
Engine is cheap to construct (~few ms cold) and the standard library installs lazily. For most embedding scenarios a single long-lived engine per process is fine. For sandboxed multi-tenant use, construct one engine per tenant and reset() between requests if you want a clean slate.
$engine = new Engine();
foreach ($requests as $req) {
$engine->reset();
$engine->setGlobal('request', $req);
$engine->execFile($req->scriptPath);
}