README.md

September 6, 2026 · View on GitHub

██████╗ ██╗  ██╗██████╗ ██╗      █████╗ ███╗   ██╗ ██████╗
██╔══██╗██║  ██║██╔══██╗██║     ██╔══██╗████╗  ██║██╔════╝
██████╔╝███████║██████╔╝██║     ███████║██╔██╗ ██║██║  ███╗
██╔═══╝ ██╔══██║██╔═══╝ ██║     ██╔══██║██║╚██╗██║██║   ██║
██║     ██║  ██║██║     ███████╗██║  ██║██║ ╚████║╚██████╔╝
╚═╝     ╚═╝  ╚═╝╚═╝     ╚══════╝╚═╝  ╚═╝╚═╝  ╚═══╝ ╚═════╝

Rust Docs Built on status license

[PHP, COMPILED TO BYTECODE — ON A SHARED CRANELIFT JIT]

"Zend compiles PHP to its own opcodes and walks them. phplang lowers PHP to a shared machine that other languages already run on, and lets a tracing JIT compile the hot loops."

phplang is PHP as a fusevm frontend — a lexer/parser and compiler that lowers PHP to fusevm::Chunk bytecode running on fusevm's bytecode VM + tracing Cranelift JIT, over a PhpHost object heap. There is no bespoke interpreter loop: phplang is a pure front end; execution and codegen live in fusevm — the same engine behind zshrs, strykelang, awkrs, pythonrs, and rubylang.

It is, to our knowledge, the first compiled standalone PHP runtime. The binary is php.

Read the Docs · Engineering Report · Builtin Reference · fusevm


Table of Contents


[0x00] OVERVIEW

phplang keeps PHP the language and throws away Zend's execution model. It lexes and parses PHP (two-mode: inline-HTML passthrough plus <?php … ?> code), lowers the AST to fusevm bytecode, and runs it on the shared bytecode VM with a tracing Cranelift JIT. Arithmetic lowers to native ops so the JIT can trace hot loops; PHP-specific behavior — loose comparison, string↔number coercion, array semantics, the standard library — is served by the PhpHost object heap.

It carries no VM or JIT of its own. Bug fixes and JIT improvements in fusevm land once and benefit every hosted frontend at the same time.

[0x01] PIPELINE

source ──▶ lexer ──▶ parser ──▶ compiler ──▶ fusevm::Chunk ──▶ fusevm VM + JIT
              │         │           │                                  │
         two-mode   PHP AST    lower to bytecode              callbacks into PhpHost
        (HTML/PHP)            (native ops + CallBuiltin)      (builtins + numeric hook)
  • Scalars (int, float, bool, string, null) ride through the VM as native fusevm::Values.
  • Arrays are heap objects in PhpHost; they travel as Value::Obj(u32) handles into that heap.
  • Arithmetic + - * lowers to native fusevm ops so the JIT can trace hot loops; a strict numeric hook supplies PHP coercion when an operand is a string/array/null or an i64 op overflows. / % **, concatenation, comparisons, and everything PHP-specific lower to CallBuiltin handlers. The hook is the sited form, so it can index the running chunk's line table and report a warning against the operator's own line.
  • PHP 8 operand rules are applied by every operator that reads a number. A string with no numeric prefix ("g", "", " ", "INF") makes the operation a TypeError: Unsupported operand types: string + int; one that merely trails garbage ("5g") raises Warning: A non-numeric value encountered and continues with its prefix. Operands resolve left before right and before each operator's own checks, so "g" / 0 is a TypeError rather than a DivisionByZeroError.

[0x02] USAGE

php script.php              # run a file
php -r 'echo 1 + 1;'        # run a one-liner (no <?php tag needed)
php < script.php            # run a script on stdin
php -a                      # interactive REPL (persistent state per line)
php --dump-bytecode f.php   # print the lowered fusevm bytecode
php --tiers f.php           # run it, then report which fusevm tiers took it

The three script entry points name the script differently, and the name is observable: every diagnostic quotes it and __FILE__ returns it. A file is its resolved path, -r is Command line code, and stdin is Standard input code — the same three names the reference uses.

A man/php.1 man page and runnable examples/*.php ship with the crate.

[0x03] SUPPORTED TODAY

A working core, grown outward from the sibling frontends. Implemented and tested end-to-end (see tests/basic.rs):

  • <?php … ?> tags with inline-HTML passthrough; <?= short echo; #, //, and /* */ comments.
  • Scalars, single- and double-quoted strings with escapes and the full set of interpolation forms — "$v", the simple one-level "$o->p" / "$a[key]" (where an unquoted key is a string, not a constant), the complex "{$expr}" for any expression, and the legacy "${v}".
  • Heredoc (<<<EOT) and nowdoc (<<<'EOT'): a heredoc body is the double-quoted language minus the \" escape (a " there needs none), a nowdoc body is verbatim to the byte. PHP 7.3's flexible closing delimiter is honoured — its indentation is stripped from every body line before anything is interpolated, and a line indented less than it is a parse error naming that line. Only the exact label closes a body, the newline before the label belongs to the delimiter, and the label may be followed by any token.
  • Variables, arithmetic (+ - * / % **), string concat (.), compound assignment (+= .= …), pre/post ++/--.
  • Loose/strict comparison (== != === !== < > <= >=, PHP-8 string↔number ordering), short-circuit && || and or, ternary ?: (incl. the elvis short form), null-coalesce ??, the nullsafe operator ?-> (a null receiver short-circuits the whole REMAINING CHAIN to null — $a?->b->c["k"]->d() evaluates no member, no subscript and no argument past the ?->, while a ?-> inside an argument keeps its own extent), !, and (int)/(float)/(string)/(bool) casts. An object with __toString compared against a string is cast to it first, as zend_compare does.
  • Indexed, associative, and appended ($a[] =) arrays with PHP value semantics — assigning, passing, returning or storing one hands over a copy (deep through nested arrays; an object inside stays a handle), while $b = &$a and a &$x parameter share; index read/write; deep and nested lvalues ($a[b][c] =, $a[b][] =, compound and ++/-- on elements); the by-reference array mutators (array_push/pop/shift/ unshift/splice).
  • if / elseif / else, while, do … while, for, foreach ($a as [$k =>] $v), switch (with fall-through), break, continue, return; match expressions (a no-arm/no-default match throws \UnhandledMatchError, as PHP 8 does). Each of the six control structures is also read in PHP's ALTERNATIVE spelling — if (…): … endif;, endwhile, endfor, endforeach, endswitch, enddeclare — including bodies cut in half by ?> html <?php, which is the spelling templates are written in.
  • print is an operator, not a statement: it writes its operand like echo and evaluates to the int 1, so $r = print "x" and var_dump(print $v) are expressions. It binds looser than = and tighter than yield.
  • The three WORD logical operators and, or and xor, at their own precedence BELOW assignment — which is the only reason the language has them next to &&/||: $x = a and b assigns a first. xor has no short circuit and answers a bool.
  • User functions with positional, default ($x = 1) and variadic (...$rest) parameters, recursion, argument unpacking at EVERY call site (f(...$args), $f(...$args), $o->m(...$args), C::s(...$args), new C(...$args)) and inside an array literal ([...$a, ...$b], keys renumbered for integers and kept for strings), either of which also drives a Generator or a Traversable, and named arguments (f(name: 1, other: 2), mixable with positional, order independent, extra names collected into a variadic); anonymous function () use (...) { … } closures — by value, and by reference with use (&$v), which binds the closure's name to the enclosing variable itself — and fn (…) => … arrow functions.
  • First-class callable syntax (strlen(...), $obj->method(...), Cls::method(...), $callable(...)) — each yields a Closure that forwards its arguments to the referenced function/method.
  • Closure rebinding: Closure::bind($fn, $obj, $scope), $fn->bindTo($obj, $scope), and $fn->call($obj, …) rebind $this and the private/protected access scope; a closure created inside a method auto-binds the current $this and class scope.
  • Generators (yield): a function whose body contains yield returns a lazy Generatoryield $v, yield $k => $v, bare yield, and yield from (delegating to an array, Traversable, or another generator). The Generator object supports current/key/next/valid/send/throw/getReturn and drives foreach lazily (side effects interleave; infinite generators work). Implemented as host-side stackful coroutines (corosensei) — the fusevm VM run loop executes on the coroutine's stack, so yield suspends it with one stack switch and no VM change.
  • Classes/OOP: new, instance properties and methods, $this, constructors (with property promotion), class constants, ::class, static methods/constants, self::/parent:: (inside a trait both name the COMPOSING class, and inside a closure the class it is bound to — (function () { return self::K; })->call($o) — both resolved when the code runs), constants inherited from an implemented or extended interface, late static binding (static::static::class, new static, static::CONST, static::$prop, static::m() — resolves to the class the call was made on, and self::/parent::/static:: calls forward it), single inheritance, interfaces (implements, interface extends), the instanceof operator, traits (use Trait; member merging, with insteadof/as adaptation blocks resolving collisions and binding extra names or visibilities — an unresolved collision is a fatal error raised when the declaration is reached, as the reference raises it), and anonymous classes (new class(args) extends P implements I { … }, named Base@anonymous after the parent, else the first interface, else class). abstract classes and interfaces reject direct instantiation (new on either is a Cannot instantiate … error). References$b = &$a, references to a container slot in either direction ($r = &$a['x']['y'], $r = &$o->p, $a[] = &$v, $o->p = &$v), return-by-reference (function &f()), foreach ($a as &$v), by-reference parameters (function f(&$x)) on every kind of call — a free function, an instance or static method, a closure, an arrow function, and a ?-> call that actually ran — with a declared scalar type coerced through the reference before the body runs (function f(int &$x) called with "5" leaves the caller's variable int(5)), and by-reference destructuring targets ([&$x, $y] = $a in both spellings, keyed, nested, alongside holes, and inside foreach, each aliasing the subject's element so a write through the target reaches the subject); a referenced element stays shared across an array copy and var_dump marks it with &, as PHP does. A & inside a value array ($arr = [&$a]) is a distinct feature and is rejected rather than copied. Namespaces are accepted in a flat model (namespace X; / use A\B\C;; qualified names fold to their short name).
  • The magic constants __LINE__, __FILE__, __DIR__, __FUNCTION__, __CLASS__, __METHOD__, __NAMESPACE__ and __TRAIT__, resolved where they are WRITTEN rather than where the call arrives: __CLASS__ in an inherited method names the class that declared it, and a closure carries the PHP 8.4 name {closure:<scope>:<line>} built from the scope it was written in (nesting composes — {closure:{closure:f():3}:4}). The two answers a parse cannot settle come from the running frame: __CLASS__ inside a trait method is the class that USED the trait, and inside an anonymous class it is the generated class@anonymous name get_class reports. __FILE__ is whatever the entry point named the script — a resolved path, Command line code for -r, or Standard input code for a script on stdin — and __DIR__ falls back to the working directory when there is no file. In the flat namespace model __NAMESPACE__ is the declared namespace in full, while class and function names stay unqualified.
  • Enums (PHP 8.1): pure enums (enum Suit { case Hearts; … } with Suit::Hearts, ->name, Suit::cases(), singleton === identity) and backed enums (enum Status: string { case Active = 'active'; } with ->value, Status::from(), Status::tryFrom()); enums may declare methods and constants and satisfy instanceof UnitEnum/BackedEnum. A case renders as the case, not as the object behind it: enum(Suit::Hearts) from var_dump, \Suit::Hearts from var_export, Suit Enum:string ( … ) from print_r, its backing value from json_encode (a pure enum fails the encode with JSON_ERROR_NON_BACKED_ENUM), and E:12:"Suit:Hearts"; from serialize, which unserialize resolves back to the very same singleton.
  • Exceptions: throw as a statement and a PHP-8 expression ($x ?? throw …), try / catch (A | B $e) / finally with finally-always semantics (it runs on return, throw, break, and continue out of the guarded body), a built-in exception hierarchy (Exception/Error disjoint roots under Throwable, plus RuntimeException, LogicException, InvalidArgumentException, TypeError, ValueError, UnhandledMatchError, DivisionByZeroError) that user classes can subclass, and getMessage()/getCode()/getPrevious()/__toString().
  • Diagnostics. Every diagnostic is written TWICE, under two independent ini flags, exactly as the reference writes it: display_errors puts the copy the program sees on stdout, interleaved with its output and inside any open ob_start buffer, and log_errors puts a PHP -prefixed copy on stderr. Both default on; -d display_errors=0 leaves only the stderr copy and -d log_errors=0 only the stdout one. A fatal and a parse error are not exempt from either, nor from the error_reporting mask — php -d error_reporting=0 runs an uncaught exception to completion in silence and still exits 255. trigger_error raises a real diagnostic through the same path, so it obeys all three gates, rejects a level that is not one of the four E_USER_* with the reference's ValueError, and ends the request on E_USER_ERROR. The diagnostics themselves cover undefined variables, array keys and properties, array offsets on a non-array, string offsets past the end, the ++/-- cases that have no effect, and PHP 8.2's Creation of dynamic property C::$p is deprecated. isset(), empty() and ?? read in PHP's isset mode and stay silent, as do writes, auto-vivification and by-reference output arguments. @ is separate and dynamic: the operand is evaluated normally and every diagnostic raised while it runs is dropped, including those raised from inside the library functions it calls (@preg_match('/[a', $s)). Which of them are displayed is the error_reporting mask, writable by error_reporting(), ini_set('error_reporting', …), or php -d error_reporting=…. Only the -d path runs the php.ini constant-expression scanner, so E_ALL & ~E_DEPRECATED is understood there and reads as plain integer 0 through ini_set — the reference behaves the same way.
  • Compile-time notices are raised while the source is READ, so they precede every byte the program writes and fire whether or not the code carrying them runs — Using ${var} in strings is deprecated, use {$var} instead is the one PHP 8.2 added. A run-time error_reporting(0) cannot retract one; only the startup level (-d) applies.
  • Attributes (#[Attr], #[Ns\\Attr(1, [2])]) parse everywhere a declaration can carry them — class, function, method, property, class constant, enum case, parameter. #[AllowDynamicProperties] is honoured, and inherited by subclasses.
  • Library argument errors throw. A standard-library function given arguments it rejects raises the catchable exception PHP raises — ValueError, DivisionByZeroError — with the library call itself as frame #0 of the trace (#0 <file>(<line>): range(9, 10, 2)), and a #[\\SensitiveParameter] argument masked as Object(SensitiveParameterValue) exactly as PHP masks it.
  • Property overloading__get, __set, __isset and __unset fire for a property the object does not carry, whether because no class declared it, because it was unset, or because its visibility puts it out of reach of the reading scope. They are consulted BEFORE the access error, so a class defining them never reports Cannot access private property. A magic method is not re-entered for the property it is already handling, so __get($n) { return $this->$n; } terminates. The four questions PHP asks differently are all distinguished: isset() asks __isset alone, empty() and ?? follow a true __isset with __get (and ?? falls back to __get when there is no __isset, where empty() does not), and a plain read asks __get.
  • Property access errors throw. Reading, writing or unsetting an out-of-reach property raises a catchable Error: Cannot access private property C::$x naming the class that DECLARED it, rather than aborting the process. isset() never throws — asking is always allowed.
  • Method overloading__call and __callStatic catch a call to a method the class does not declare or cannot reach, receiving the name plus one array of the arguments. The instance and static forms are distinct: a class with only __call does not answer C::m(). Every call form routes through them, including call_user_func([$o, 'm']), so is_callable([$o, 'm']) is true for a name only the catch-all handles while method_exists stays false.
  • __invoke makes every instance of the declaring class callable, and it is recognized wherever a callable is: $obj(…), is_callable($obj), and any builtin taking a callback (array_map, usort, array_filter, Closure::fromCallable). One decoder in host::call_value reads every callable form — closure handle, "function", "C::m", [$obj, "m"], ["C", "m"], and an __invoke object — so no two call sites can disagree about what is callable.
  • Call errors throw. Call to undefined method C::m(), Call to undefined function f() and Call to private method C::m() from global scope are all catchable Errors, with the trace starting at the call site (no frame is invented for the callee that does not exist).
  • ArrayAccess$o[k], $o[k] = v, $o[] = v, isset($o[k]) and unset($o[k]) dispatch to offsetGet/offsetSet/offsetExists/offsetUnset on any class implementing it. isset() asks offsetExists and nothing else; ?? follows a true offsetExists with offsetGet. Countable backs count(), which rejects anything else with the reference's TypeError rather than answering 1. The SPL prelude classes declare both, so $fixedArray[0], $arrayObject['k'] and count($storage) work through the ordinary syntax.
  • Stringable is implied by __toString, whether or not a class names the interface, exactly as PHP 8 implies it.
  • Closures and generators are objects. A closure is an instance of Closure and a generator one of Generator to is_object, get_class, ::class, get_debug_type, instanceof, is_a, is_subclass_of, method_exists, get_object_vars and every diagnostic that names a value's type — and $gen instanceof Iterator / Traversable is true, through an engine ancestry table rather than a class declaration. Closure is also the one class the reference gives an identity-only ==.
  • class_exists/interface_exists/trait_exists/enum_exists each answer for ONE declaration kind: an interface I {} makes interface_exists('I') true and class_exists('I') false, while an enum is both an enum and a class. The engine's own types answer too — Closure and Generator as classes, Traversable, Iterator, IteratorAggregate, Countable, ArrayAccess, Stringable, JsonSerializable, Throwable, UnitEnum and BackedEnum as interfaces. class_implements and class_uses report the real lists.
  • __toString is invoked wherever a value becomes a string: echo, print, . concatenation, interpolation, the (string) cast / strval, implode's elements, and the library functions whose parameters PHP declares as string.
  • Integer literals in every base (0xFF hex, 0755/0o17 octal, 0b101 binary, 1_000 separators); predefined constants (PHP_INT_MAX, PHP_EOL, M_PI, the SORT_*/FILTER_*/JSON_*/… flag families) plus define/defined/constant and the const NAME = expr, …; declaration (top-level, as PHP's grammar has it — a redefinition through either spelling warns and keeps the first value); and superglobals ($_SERVER, $_ENV, $_GET/$_POST/…, $GLOBALS, $argv/ $argc) auto-global across every scope.
  • The DateTime/DateTimeImmutable/DateInterval classes and the SPL data structures (SplStack, SplQueue, SplDoublyLinkedList, SplFixedArray, ArrayObject, SplObjectStorage, SplPriorityQueue, SplMinHeap/SplMaxHeap) plus stdClass, all as PHP preludes; output buffering (ob_start/ob_get_clean/…), variadic introspection (func_get_args/func_num_args), fopen file streams (fread/fwrite/fgets/fseek/fclose), the unset() construct, spl_object_id, and the @ error-suppression operator.
  • exit / die — the request ends where they stand. Parentheses and the argument are both optional; an int becomes the process exit status (modulo 256, so exit(300) leaves 44 and exit(-1) leaves 255), a string is printed and the status is 0, a bool or float narrows, an explicit null is deprecated, and anything else is a TypeError. The unwind is not catchable and does not run a finally, but open output buffers still flush. PHP 8.4 also registered them as callable functions, so function_exists("exit") is true and $f = "exit"; $f(3); works.
  • A large standard library (see the builtin reference for the current surface — it is generated from the same corpus a test pins to the runtime's registration tables, so it cannot drift), incl. bcmath and gmp arbitrary precision, split into category modules under src/stdlib/ and consulted through a per-category dispatch chain:
    • stringsstr_*, substr*, strpos/stripos/strrpos, strstr, strtr, sprintf/vsprintf/sscanf, number_format, nl2br, addslashes, str_rot13, similar_text, levenshtein, mb_*, …
    • arraysarray_map/filter/reduce/merge/slice/column/chunk, the sort/usort/natsort families, array_diff/intersect (+_key/ _assoc), compact/extract, the internal-pointer family, …
    • mathabs/floor/ceil/round/sqrt, full trig + hyperbolic + inverse, hypot/fdiv/fmod, base conversions (dec*/*dec/base_convert), rand/mt_rand/random_int.
    • ctype — the ctype_* predicates. typesis_*, gettype, get_debug_type, serialize/unserialize, var_dump/print_r/var_export. Objects render in full: print_r annotates a non-public property ([b:protected], [c:P:private]), var_export emits \P::__set_state(array( … )) ((object) array( … ) for a stdClass), and serialize mangles the property keys the way the engine stores them ("\0*\0b", "\0P\0c"). unserialize restores an object WITHOUT running its constructor, turns an unknown class into __PHP_Incomplete_Class, and reproduces the reference's diagnostics — including the byte offset it blames and the "Extra data" case, where trailing bytes warn but keep the value.
    • pregpreg_match/match_all/replace/replace_callback/split/ quote/grep (byte-mode by default, Unicode with /u). Look-around, backreferences, atomic groups and possessive quantifiers all work: a pattern the regex crate will not compile is retried on fancy-regex. $matches is a real by-reference OUT parameter, so it defines the caller's variable whether or not it existed, and a (?<name>…) group appears under its name as well as its index.
    • datetimetime/mktime/date/gmdate/checkdate/strtotime (UTC).
    • hashmd5/sha1/hash/crc32/hash_hmac. encodingbase64_*, bin2hex/hex2bin, quoted-printable, utf8_*. urlurlencode/rawurlencode (+decode), http_build_query, parse_url, parse_str (with the PHP_URL_* component selectors).
    • jsonjson_encode, json_decode (objects to stdClass, or to arrays under $associative / JSON_OBJECT_AS_ARRAY), json_last_error(_msg). filterfilter_var (VALIDATE_INT/FLOAT/BOOLEAN/EMAIL/URL/IP/DOMAIN/ REGEXP, SANITIZE_*). mbstringmb_str_split, mb_convert_case, mb_strpos/rpos, mb_ord/chr, mb_convert_encoding, mb_detect_encoding.
    • fileiofile_get_contents/put_contents, file, fopen-free file ops (file_exists, is_file/dir, unlink, mkdir, scandir, copy, basename/dirname/pathinfo, realpath, getcwd, …).
    • reflectionclass_exists, method_exists, property_exists, get_class, get_parent_class, get_object_vars, get_class_methods, class_parents, is_a/is_subclass_of. callablecall_user_func(_array) (incl. array/Class::method callables), function_exists. miscstrnatcmp/strnatcasecmp, soundex, str_getcsv, array_walk_recursive, array_find/array_any/array_all, array_udiff/array_multisort.
    • systemgetenv/putenv, phpversion, php_sapi_name, php_uname, getmypid, extension_loaded, get_defined_constants, get_declared_classes.

[0x04] NOT YET (LATER WAVES)

True (non-flat) namespaces with as alias remapping. A few current deviations, documented in-code:

  • Only a SCALAR type declaration is enforced — int, float, string, bool and their ? nullable forms, on a parameter or a return. Every other type is parsed and carried but checks nothing: a union (int|string), an intersection, a class name, array, iterable, callable, mixed, object, and the return-only void/never/static. A value that would not satisfy one of those passes through where the reference raises a TypeError.
  • declare(strict_types=1) is whole-program rather than per-file. Upstream reads the mode from the file containing the CALL, so a strict file calling a non-strict file's function still checks strictly; phplang has no include, so a run is exactly one file and the two readings coincide. If include is added, this becomes a real divergence and the flag has to move onto the call site.
  • A callback invoked BY a library function (array_map, usort) is checked in whatever mode the program declared. Upstream treats an internal caller as having no strict-mode file and so coerces, while call_user_func forwards the caller's mode; phplang forwards it in both cases.
  • The UNCAUGHT rendering of a parameter/return TypeError names the CALL site where the reference names the function's DEFINITION — in file:9 rather than and defined in file:2 — because no definition line is recorded for a function. getMessage() itself is byte-exact, which is what a catch sees.
  • Default parameter values are not restricted to constant expressions, and a default is not checked against the parameter's declared type (upstream checks it once, at declaration).
  • The by-reference OUT parameter is implemented for preg_match/ preg_match_all/preg_replace(_callback)/parse_str/similar_text/ str_replace/settype/array_multisort and not for the rest of the library — sscanf's trailing arguments are the one that remains, so sscanf($s, "%d %s", $a, $b) returns the parsed array where the reference returns the count and fills $a/$b. The two-argument form is exact.
  • A diagnostic names the statement's line. PHP names the line of the expression, so a statement spanning several lines reports its first.
  • A preg_* pattern the REFERENCE also rejects reproduces its Warning and its preg_last_error() state. Pattern RECURSION is the one construct that now compiles and answers WRONGLY rather than failing: preg_match('/\((?:[^()]| (?R))*\)/', '(a(b))') matches in both engines, but the reference captures (a(b)) where this one captures (b). There is no diagnostic to copy and no error sentinel to return — the answer is simply not the reference's, which makes it the most dangerous shape a gap can take. Conditional groups ((?(1)…|…)), look-around, backreferences, atomic groups and possessive quantifiers all match the reference.
  • A pattern that only the fancy-regex engine will take matches as if /u were set, because that engine works over &str: . is one codepoint rather than one byte. This is visible only for a NON-ASCII subject, and only for a pattern the byte engine already refused.
  • A syntax error reproduces PHP's unexpected <token> text but not the , expecting "X" or "Y" clause that often follows it: the expected set comes out of PHP's generated LALR tables, not the grammar as written here.
  • var_dump's #N object number and spl_object_id agree with each other, but PHP reuses a freed object's number and phplang's arena never frees, so the two agree only until an object becomes unreachable.
  • ini_get/ini_set know PHP core plus date and pcre — the two extensions PHP 8 cannot be built without — at the values the reference reports for them with no php.ini loaded. A name belonging to an optional extension, or one whose default is the build's install prefix (extension_dir), reads back false rather than a machine-specific guess. ini_set does not model per-setting VALUE validation: the reference refuses ini_set('memory_limit', '20') with a message quoting its live memory usage, which is not a reproducible number.

Persistent bytecode caching and AOT (--build) — present in the sibling frontends — are not wired yet; an LSP server (--lsp) and a DAP debug adapter (--dap, with source-line and function breakpoints, stepping, call stack, locals, and expression evaluate) are.

[0x05] PARITY FUZZER

parity-fuzz is a differential fuzzer: it generates seed-deterministic PHP snippets, runs each through both the reference php and phplang, and reports every case where stdout, stderr or the exact exit code differs. It is a development tool — it needs a reference php on PATH, so CI never runs it. Neither side is run with error_reporting turned down: PHP writes Warning/Deprecated/Fatal error to stdout and a PHP -prefixed copy to stderr, so both copies are part of what is compared.

All three observables are compared because each omission was a blind spot rather than a decision. stderr used to be piped to /dev/null, which made the harness structurally incapable of reporting a stderr-only divergence however many cases it ran; the exit code used to be compared only as zero-vs-nonzero, which made exit(3) and exit(9) the same answer.

cargo build --bin parity-fuzz
./target/debug/parity-fuzz --count 5000            # fuzz 5000 cases
./target/debug/parity-fuzz --count 500 --mode enums   # 500 cases of ONE mode
./target/debug/parity-fuzz --once --seed 1234         # replay one case, both sides

--mode NAME generates that mode's cases rather than filtering the rest away, so --count 500 --mode enums compares 500 enum programs. A divergence prints the case SEED, which --once --seed <it> rebuilds exactly; add the same --mode when the run that found it was filtered. PHPLANG_FUZZ_PHP pins the oracle and is refused unless it is a reference PHP — pointing it at phplang would compare the binary under test against itself and report a clean sweep.

Generators are biased toward where a PHP frontend is likely to disagree with the reference: float formatting, integer division/modulo signs, ** precedence, loose-vs-strict comparison, sort ordering, sprintf/number_format, and string↔number coercion, and the PCRE constructs the regex crate lacks. Divergences are delta-debugged to a minimal reproducer and grouped by signature; a full report lands in target/parity-fuzz/divergences-<pid>.txt, named for the run so concurrent invocations against one checkout cannot overwrite each other.

The exit status answers did this run measure what it was asked to, not merely did it find a disagreement. A run exits non-zero when no cases ran, when every case that ran was skipped (the reference timing out on all of them), when a case reached a worker and produced no verdict, or when a case agreed only because the reference printed nothing. Each is named in a closing RUN NOT CLEAN line, and the summary reports the skipped and barren counts even at zero — a clean number is only evidence if those are visible next to it.

A clean run over the existing modes proves nothing about a construct no generator emits, and that is where the divergences have actually been. Before picking a target, cross-reference the generator modes against the registered library surface:

# every registered builtin that no generator mode ever emits
grep -rhoE '"[a-z_][a-z0-9_]{2,}"' src/stdlib/*.rs src/builtins.rs | tr -d '"' \
  | sort -u | while read f; do
      php -r "exit(function_exists('$f')?0:1);" &&
        ! grep -q "$f" src/bin/parity_fuzz.rs && echo "$f"
    done

Round 7 found json_decode, ctype_*, parse_url, strip_tags, __invoke, compact, md5 and base64_* on that list, and every one of them was carrying a bug. Round 8 found sscanf, addcslashes, count_chars, strtok, substr_compare and the array forms of substr_replace on it, with the same result. Round 10 ran the same test over the SYNTAX instead of the library — a grep for <<<, yield, enum , trait , ... and a ?-> past the first link returned zero hits over the generators — and every one of those six was carrying a bug too, <<< not being implemented at all. Round 11 repeated it and found endif/endwhile/endfor, clone, __call, get_class, is_a(, class_exists and func_get_args at zero: the entire alternative control- structure syntax was a parse error, and the reflection surface did not know a closure was an object. A curated corpus cannot report a construct nobody captured, so a mode should be added for the family FIRST and the fix written against what it reports.

Sampling the corpus is itself easy to get wrong. The mode is chosen from seed >> 7, so consecutive seeds share one — seeds 1..6000 reach only the first 47 of the 82 modes, and a survey over them will report every later mode's constructs as absent. Sample per mode instead:

for m in $(perl -ne 'print "\$1\n" if /name: "([a-z0-9_]+)",/' src/bin/parity_fuzz.rs); do
  seq 1 60 | xargs -P 12 -I{} target/debug/parity-fuzz --once --mode "$m" --seed {} \
    | perl -ne 'if(/^prog  : /){$p=1; s/^prog  : //} elsif(/^oracle: /){$p=0} print if $p'
done > corpus.txt

A clean divergence count only means something alongside the two numbers printed under it. skipped counts cases that never reached a comparison — the reference timed out, or either side failed to run — because a mode whose programs all time out on the reference would otherwise report zero divergences forever. barren counts cases where both sides agreed only by failing with nothing on stdout: those ran and matched, but prove nothing about the behaviour they were written to exercise, and two different failures are indistinguishable there. A run is worth quoting when both are 0.

[0x06] BUILD

cargo build
cargo test

phplang is a standalone crate (an explicit empty [workspace] stops cargo walking up to the meta parent). fusevm is pulled from crates.io with the jit feature.

[0x07] DOCUMENTATION

[0xFF] LICENSE

MIT — free and open source. See LICENSE.