Architecture
Phasis is a four-stage tree walker with an opportunistic bytecode VM. The data path is:
JS text
│
▼
┌──────────┐
│ Lexer │ src/Lexer/
└────┬─────┘
▼ tokens
┌──────────┐
│ Parser │ src/Parser/
└────┬─────┘
▼ AST nodes
┌──────────────┐
│ Interpreter │ src/Runtime/
│ + Bytecode │ src/Bytecode/
│ VM │
└────┬─────────┘
▼ JsValue
PHP host
Each stage is a PHP class with a small surface that the next stage consumes. There's no link step — parsing produces an AST, the interpreter walks it directly.
Lexer
src/Lexer/Lexer.php converts source text into a stream of Token instances. Each token is a readonly struct: type (TokenType enum, ~80 cases), value, line, column.
The lexer handles:
- Number literals — decimal, hex (
0x), octal (0o), binary (0b), exponential, numeric separators. - String literals — single and double quotes, all escape sequences, template literals with nested expressions.
- Regular expression literals — full
/pattern/flagstokenisation with awareness of when a/should be regex vs division. - Identifiers, keywords, punctuation.
- Comments (line and block), preserved as trivia.
- Unicode source — UTF-8 bytes interpreted as the JS UTF-16 source.
Output is a generator that yields tokens lazily. The parser consumes them with one-token lookahead.
Parser
src/Parser/Parser.php is a hand-written Pratt parser. Expression precedence lives in src/Parser/Precedence.php. Statements have their own recursive descent.
It produces immutable AST nodes from src/Ast/. Every node carries its source location for stack traces and error reporting.
Notable shapes:
- Expression:
Literal,Identifier,BinaryExpression,UnaryExpression,AssignmentExpression,CallExpression,MemberExpression,ArrowFunction,ObjectExpression,ArrayExpression,ConditionalExpression,TemplateLiteral,SpreadElement,NewExpression,ThisExpression,SequenceExpression,TaggedTemplate,ClassExpression,YieldExpression,AwaitExpression. - Statement:
BlockStatement,IfStatement,ForStatement,ForInStatement,ForOfStatement,WhileStatement,DoWhileStatement,SwitchStatement,ReturnStatement,ThrowStatement,TryStatement,BreakStatement,ContinueStatement,ExpressionStatement. - Declaration:
VariableDeclaration,FunctionDeclaration,ClassDeclaration,ImportDeclaration. - Pattern (destructuring):
ArrayPattern,ObjectPattern,RestElement,AssignmentPattern.
Automatic semicolon insertion is implemented in the parser (not the lexer) per spec.
Interpreter
src/Runtime/Interpreter.php is the AST walker. It visits each node and returns a JsValue (or a Completion for statements that change control flow).
Key supporting classes:
Environment(src/Runtime/Environment.php) — lexical scope chain. Each function call creates a new environment pointing to its lexical parent.varhoists to the function scope;let/constare block-scoped with a TDZ.CallStack(src/Runtime/CallStack.php) — explicit stack with depth limit. Function and method calls push frames; exceptions unwind them.Completion(src/Runtime/Completion.php) — discriminated union ofnormal,return,throw,break,continueplus an optional value and label.Reference(src/Runtime/Reference.php) — spec Reference type for left-hand-side evaluation in assignments anddelete.
Values
JavaScript values are PHP objects under src/Value/. Each one implements the JsValue interface and carries its own type-conversion methods (toNumber(), toString(), toBoolean()).
| Class | JS type |
|---|---|
JsUndefined |
undefined (singleton) |
JsNull |
null (singleton) |
JsBoolean |
true / false |
JsNumber |
IEEE 754 double; preserves NaN, ±Infinity, ±0 |
JsString |
UTF-16 string semantics |
JsObject |
base object with property map and prototype chain |
JsArray |
extends JsObject with length tracking |
JsFunction |
closure with captured Environment |
JsSymbol |
Symbol primitive |
JsBigInt |
arbitrary precision via bcmath |
JsProxy |
all 13 traps, revocable |
JsPromise |
synchronous executor + microtask queue |
JsGenerator |
dual-mode: frame-snapshot (bytecode-compiled bodies; see Bytecode VM § Frame-snapshot generators) with PHP Fiber fallback for yield*, try-with-yield, and async generators |
JsMap, JsSet, JsWeakMap, JsWeakSet, JsWeakRef, JsFinalizationRegistry |
collections |
JsArrayBuffer, JsSharedArrayBuffer, JsDataView, JsTypedArray |
binary data |
Number handling is the single largest source of historical bugs: NaN !== NaN must be true, -0 === 0 must be true, ToNumber edge cases need exact bit-level care. The Spec/TypeConversion class implements the spec algorithms verbatim and is exhaustively tested.
Built-in library
src/BuiltIn/ provides every standard-library object: Array, String, Object, Math, JSON, Date, RegExp, Map, Set, Promise, Proxy, Reflect, Symbol, BigInt, TypedArray, Temporal, Intl, console, plus the Web Platform / Fetch packs (URL, Headers, Request, Response, Blob, FormData, full Streams, EventTarget, AbortController/AbortSignal, DOMException, structuredClone, performance, TextEncoder/TextDecoder, atob/btoa), crypto + SubtleCrypto, WebSocket, XMLHttpRequest, and the event loop (setTimeout, setInterval, queueMicrotask, AsyncContext).
Each built-in is a static install() method on a class. The Engine calls them in dependency order at construction. Each one defines its prototype chain, installs methods as JsFunction instances wrapped around PHP closures, and exposes the constructor as a global.
PHP↔JS bridge
Value conversion across the boundary lives in two places:
Phasis\Interop\PhpToJs::convert()— PHP value toJsValue, used bysetGlobal(),call()arguments and host-function return values. A PHP callable becomes aJsFunctionwhose body calls it.PhpToJs::toPhp()—JsValueto PHP for the arguments of a host function.Engine::toPhp()(private) —JsValueto PHP for the results ofeval()andcall().
There is no object bridge: PHP objects that are not callable convert to undefined (issue #18). The JsToPhp class in src/Bytecode/ is unrelated to interop; it is the transpiler described below. Conversion rules are documented in Value conversion.
Error handling
An uncaught JS throw becomes a PHP Phasis\Exceptions\JsThrowable; errors the engine raises itself surface as Phasis\Exceptions\TypeError, RangeError or ReferenceError, all subclasses of Phasis\Exceptions\RuntimeError. Inside the engine, a JS try / catch catches JsThrowable, any RuntimeError and Phasis\Exceptions\SyntaxError. Other PHP exceptions, including those thrown by host functions, are not converted and propagate straight to the PHP caller (issue #20).
Two limits exist: the call-stack ceiling (CallStack::maxDepth, 4,096 frames, plus an 8,192-frame guard for transpiled closures) and maxLoopIterations, set through Engine::setLimit(), which only the tree-walker enforces (issue #19). Both throw Phasis\Exceptions\InternalError. There are no string-length, output-size or execution-time limits.
Bytecode VM
Hot functions get lowered two ways. JsToPhp (in src/Bytecode/) emits a PHP closure for numeric / locals-heavy bodies that PHP's tracing JIT can compile to native; the pipeline lowers new Ctor(args), obj.method(args), and this.member reads, which is enough to make the canonical class-method-in-a-loop pattern bypass the VM entirely. Everything else compiles to flat bytecode consumed by VM::execute, with a custom callstack that keeps every JS call inside a single PHP frame and a per-PC inline cache for prototype-method reads. Simple generator bodies compile to bytecode too, with Op::YIELD capturing a heap frame snapshot instead of crossing PHP Fiber.
Any shape the compiler doesn't lower (e.g. with, try with a re-binding catch, yield*, async generators) falls back to the tree-walker. See Bytecode VM for the full mechanism.