Phasis

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 front

A 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): mixed

Parses 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): mixed

Loads 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): void

Binds a global variable visible to all subsequent JS evaluation. PHP values are converted to JS:

PHP typeJS type
nullnull
boolboolean
int / floatnumber
stringstring
array (list)Array
array (assoc)Object
Closure / callablefunction
objectobject (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 reference

Inside JS:

console.log(config.version);   // "1.0"
log('hello');                  // calls the PHP closure
post.title = "Edited";          // mutates the PHP object

call

public function call(string $name, mixed ...$args): mixed

Calls 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);   // 5

setLimit

public function setLimit(string $limit, int $value): void

Runtime-tunable resource limit. Exceeding it throws Phasis\Exceptions\InternalError from inside JS, which the host can catch as Phasis\Exceptions\JsThrowable.

LimitDefaultDescription
maxLoopIterations100000maximum 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(): void

Clears all user-defined globals and rebuilds the standard library. The engine instance is reusable after reset().

repl

public function repl(): void

Drops 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\EventLoop

eval() 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): void

Replace 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): void

Pre-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): void

Opt-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

ClassWhen
Phasis\Exceptions\JsThrowableuncaught JS throw reaches the PHP caller
Phasis\Exceptions\SyntaxErrorparse-time error
Phasis\Exceptions\RuntimeErrorhost-side problem (file not found, IO error, etc.)
Phasis\Exceptions\InternalErrorresource 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);
}

On this page