Skip to content

feat: PHP compatibility batch (parser forms, checker convergence, references, dynamic includes, builtins) - #1886

Draft
JGamboa wants to merge 198 commits into
illegalstudio:mainfrom
JGamboa:feat/php-compat-batch
Draft

JGamboa wants to merge 198 commits into
illegalstudio:mainfrom
JGamboa:feat/php-compat-batch

Conversation

@JGamboa

@JGamboa JGamboa commented Oct 18, 2026 •

Copy link
Copy Markdown

Refs #1887

Update: the branch now merges main at f96ee9e4a (see "Merge with main") and
includes the reflection fix that was pending, so the four
var_dump_object_tests::reflection_subclass_* tests run again. Both arrived as new
commits; the existing history was not rewritten or force-pushed.

Summary

A batch of PHP compatibility fixes, one topic per merged branch, discussed in #1887.
Each topic is a PHP behavior elephc did not support or compiled into something that ran
differently from PHP. Expected outputs come from php 8.5 on the same source, and the
php-src .phpt suite is the regression net.

  • Base: main at f96ee9e4a, merged into the branch (no rebase, no force-push).
    The branch was first based on fc977b099 (v0.27.3); main's 66 new commits came in
    through the merge 20f8a154f, described in "Merge with main (f96ee9e4a)" below.
  • About 490 files, +46k/-1.4k lines (≈13k of them tests), 600+ new tests.
  • Commits are grouped by topic: each topic is a Merge branch 'fix/<topic>' with its own
    commits underneath, so the PR can be reviewed one merge at a time
    (git log --first-parent, then each merge's second parent).
  • After the topic merges come the throw fix (topic 31), the manifest documentation, the
    .plans file, and housekeeping (comment translation, regenerated builtin docs,
    assembly-comment alignment), then the merge of main with its follow-ups and the
    reflection fix (8ab9a62, 189575c).

Topics

Commit SHAs are those of the branch. Risk is how likely the topic is to change behavior
outside the pattern it targets.

# Topic Merge / commits Tests Risk
1 match (true) arms narrowed by their instanceof condition 1ed791c codegen::checker_converge Low
2 fwrite on a non-blocking stream returns int(0) on EAGAIN 6b513e1 codegen::io::file_open_failures Low
3 Statement forms (attributes on const, destructuring in while, literal-led statements, include into a static property, static $a, $b lists, parser recovery) 0363bcf (dbfdd2a, a2cedf1, b5500b3, 246625b, 5532419) codegen::parser_stmt_forms, parser recovery tests Low
4 Reserved keywords as qualified-name segments; sole \self/\parent/\static stay keywords f538cf0 (ce96e8d, 048bc2d) codegen::parser_names, error_tests::syntax Low
5 Inline HTML, ?>, <?= 3d6e273 (4ad7017, 9c34cfa) codegen::inline_html, error_tests::inline_html, lexer tests Low-Medium (short_open_tag=off semantics)
6 Widen params through chained calls 83be1f4 (75999a7) codegen::chained_call Low
7 Open/write failure paths of the file helpers 5cbcbc4 (267e3b8, 0a79cad, 5cd853c) codegen::io::file_open_failures Medium (file_put_contents/fputcsv return `int
8 Enums implement UnitEnum/BackedEnum implicitly (PHP's marker-interface errors), SessionHandlerInterface, ReflectionType as the common parent. WeakMap, ErrorException, Serializable and the enum interfaces themselves now come from main's Core classes (see the merge section) 5bda740 (7adbbcb, 1430b96), e4b3f9d codegen::native_classes, error_tests::native_classes Low
9 One call ABI per override family of dispatched methods 0ad6e88 (e2b98fe, 4ee38f7) codegen::oop::override_family_abi Medium-High (design point 1)
10 Generics: method templates erased on dispatched methods, instanceof on templates, hidden marker interfaces 0aba284, c23eb4e, 1426f98 (d209c34, 7101b51, eaf5f7e) codegen::generics* Medium
11 Stdlib prelude builtins (preg_quote, strspn, strcspn, strrchr, strip_tags, substr_compare, get_debug_type, http_build_query, version_compare, random_bytes, pack, unpack) 5ee6bd3 (01ee9ce, ba1a9f1), b1a58cc codegen::stdlib_builtins Low per builtin (design point 4)
12 Expression forms and dynamic class access ($class::X, $obj::$prop, $obj::m(), new class-name references, call()[] = $v, string offsets on mixed) 2f8e771 (527a9af, 5cb399c, a73b6c9, 85b5640, 338eb75, 662267d, cdf094f) codegen::parser_expr, codegen::oop::dynamic_class_members Medium
13 PHP-compatible override signatures (LSP) through the family ABI bd66300 (fe9924d … 46d9e80) codegen::oop::override_compatibility, error_tests::override_compatibility Medium-High (design point 1)
14 Ternary/merge typing from lowered branch values; catchable UnhandledMatchError 5b9f03a (f522bab, 106cd89, 35b0355) codegen::control_flow::ternary_union Medium
15 Assignments as expressions in all positions; possibly-undefined locals a30e510, 7492072 (a5cdd21, 21a709c, c2efbf7, …) codegen::assign_expr, error_tests::assign_expr Medium
16 Coerce declared scalar returns at runtime 87cfd0c (0f9ab8c, 915347e, d38b82a) codegen::return_coercion Medium (DCE keeps f("z");)
17 Variable variables, compact(), extract() eef5fa7 (1c8b4fd, 062a81a) codegen::variable_variables, error_tests::variable_variables, parser_tests::variable_variables Medium
18 First-class callables from runtime values; ReflectionFunction over them 040ff04 (87bc0ad, 5d00230, 00b0a8e) codegen::callables::fcc_variable Medium
19 include/require as expressions 7386b02 (891887e … 51ccb2f) codegen::type_builtins::includes::value_positions, error_tests::io_builtins::includes Medium
20 References into array elements and properties 66b2f18 (ba73730, b8cf7e0, f19282f) codegen::reference_places High (design point 2)
21 Dynamic members and calls 668d941 (ce620d6) codegen::dyn_member Medium-High
22 Checker driver: monotonic method-variadic fixpoint, quadratic work removed, file:line:col diagnostics, post-autoload preludes 87de4c6 (9e4930d, 819511c, 412500b, 6b91b5f, 9758a2e) autoload_diagnostics_tests, types::checker::driver::tests Medium
23 extract(EXTR_SKIP) respects use() captures a2fadd0 (846c34b, f4afa3c, f2a1318) codegen::variable_variables, error_tests::variable_variables Low
24 Static member forms (++self::$x[i], self::${$p}, magic constants in class constants) ff39858 (fd36a18 … 9d08262) codegen::static_member, error_tests::static_member Medium
25 &$a[], $a[] = &$x, references to runtime-named properties d561cd2 (b1b27e0, df43369, c76d962) codegen::reference_append High
26 Fold if (! function_exists()) polyfills at top level; use inside yield; ->name/->value through enum interfaces d55b7c3 (dac5030, 1865a39, c023c01, 5c108b8) codegen::cond_decl Medium
27 Lattice join for widened types; checker converges on large codebases ffee79c (e7a2002, a5cbe25, 734e92b, 3a49307) codegen::checker_converge, types::checker::lattice Medium-High (design point 5)
28 Nested array writes with non-literal keys b736c37 (08e7c80 … 9496ac1, incl. a revert) codegen::arrays::nested_string_keys Medium
29 Reference cycles in var_dump/print_r/json_encode/serialize/===; readonly never a reference cell 61bed0c (5f0fc20 … ed3f44a) codegen::reference_cycles Medium (guard stacks are 512 KB of bss each)
30 Static property references and untyped static locals 9bd41d4 (bce449c … 46854a3) codegen::static_ref, error_tests::static_ref High
31 throw accepts mixed and Throwable-or-null unions with the runtime check; throwing an Object-typed null raises Error (explicit extension of main's compile-time rule) cc213eb, 69a00a6 codegen::exceptions, error_tests::exceptions_enums_magic Low-Medium
— Dynamic-include manifest (elephc.includes.toml, --dynamic-includes=report, --include-manifest), documented in docs/compiling/dynamic-includes.md 0b40287 (4901478 … 5d28c82), 0c6c436 codegen::dynamic_include Medium (design point 3)
— Virtual hooked properties are not rendered by var_dump/print_r/var_export dc8b7d4 var_dump_object_tests::virtual_hooked_property_is_not_rendered Low

Upstream tests changed

Four upstream tests were adjusted to this branch's semantics:

  • generics_docblock_methods::test_one_line_block_reads_its_opening_tag (from c769f6c):
    the method cases now expect PHP's output. This branch drops a method template that no
    parameter or return position adopts (docblock/members.rs), so the method compiles
    untyped and runs like php-src (seven) instead of failing with "does not determine type
    parameter ". The function and multi-line assertions are unchanged.
  • parser_tests::expressions::operators::test_unary_plus_precedence (from 85ac9b9):
    both sides added unary +. This branch keeps php-src's lowering, expr * 1
    (zend_compile_unary_pm), which keeps -0.0 and raises "Unsupported operand types:
    array * int" like PHP; main lowered it to 0 + expr. The test now expects the
    * 1 tree; the precedence it checks is the same, and main's BcMath +$n tests pass.
  • error_tests::core_symbols::test_core_symbols_enum_interface_misuse: enum Bad implements UnitEnum now expects PHP 8.5's message, "Enum Bad cannot implement
    previously implemented interface UnitEnum", which this branch already reported.
  • ir_lower::tests::call_coercion_owners::eval_class_name_results_release_bridge_cells_and_strings_on_all_targets
    counts only main's two lookups: with the Core class declarations, the program also
    retains this branch's declared-return coercion helper, whose own get_class() calls
    are concrete (not eval-bridged). Every lookup must still release its result.

The earlier adjustment of serialize::not_serializable is gone: main rewrote that test
itself (namespaced Demo\WeakMap/Demo\ReflectionType), and its version is kept.

Merge with main (f96ee9e4a)

main moved 66 commits since fc977b099. They were merged into the branch with
20f8a154f (signed, no rebase), followed by two adaptation commits and the regenerated
builtin docs (6101823). The conflicts and how they were resolved:

  • Builtin contract catalogs (catalog_{classes,constants,data}.rs, registry.rs,
    support.rs): union of both sides' entries in catalog order; the registry counts add
    main's fourteen __elephc_bcnumber_* helpers to this branch's additions.

  • ir_lower/function.rs: the possibly-undefined frame flags (topic 15) are initialized
    before main's native Core method bodies (core_methods::lower) and the ordinary body.

  • ir_lower/stmt/nested_array_writes.rs: main's rooted write operands
    (root_owned_call_operand / retire_owned_call_operand) with this branch's early read
    of a bare-variable right-hand side and its container acquire (topic 28).

  • ir_lower/expr/{assignments,property_access}.rs: main's WeakMap paths run first,
    then this branch's reference-literal and transient-receiver paths (topics 20 and 25).

  • Checker schema (classes/interfaces.rs, classes/methods.rs, enums.rs): main's
    tentative Serializable returns and its WeakReference::create/unserialize signature
    tweaks, on top of this branch's #[\ReturnTypeWillChange] handling, class-scoped method
    signatures and enum interface closure.

  • pipeline.rs: main's Core and BcMath preludes next to this branch's
    autoload-aware hash prelude (topic 22).

  • Same feature on both sides: unary + (see above) and the builtin SPL
    StaticMethodCall discovery arm. One arm each is kept; the SPL one is main's, with
    this branch's case-insensitive parent/self/static match.

  • Core classes (e4b3f9d): main now declares WeakMap, WeakReference and
    ErrorException (core_prelude) and UnitEnum, BackedEnum and Serializable
    (builtin_interfaces). Both sides declaring them broke every program naming WeakMap
    ("Cannot declare class WeakMap") and every compile ("Cannot redeclare built-in interface:
    UnitEnum"). This branch's natives_prelude is removed in favor of main's classes, which
    are truly weak and accept a caller-supplied file and line, so the branch's
    ErrorException filename/line refusal and its legacy-Serializable refusal are gone too.
    What remains of topic 8 is the enum marker-interface checks, SessionHandlerInterface and
    ReflectionType. main's redeclared-interface error now points at the user's
    declaration.

  • Internal Core prelude classes (5b263e3): main's __ElephcWeakMapIterator is a
    program-level declaration, so get_declared_classes() listed it and php-src's
    instantiate_all_classes.phpt crashed instantiating it. The declared-class list now
    filters internal names on both halves.

  • Checker outer rounds (f287126): main's Core classes raise the outer round budget,
    so the branch's oscillation detector (topic 27) now saw an alternation the smaller budget
    used to cut off: a CallbackFilterIterator over a runtime callable array widens the
    pinned $callback property, and patch_builtin_spl_storage_signatures pins it back
    before every method pass. Rounds are now compared after that patch, which is what the
    next round reads; the five spl::{decorators,recursive}::*runtime_selected* tests pass.

Reflection fix (8ab9a62, 189575c)

new ReflectionClass('Mine') with class Mine extends ReflectionClass exhausted the 8 MiB
heap. Reflection metadata is built eagerly, and the nested reflectors of a builtin
Reflection class listed its ~37 private backing slots and the synthetic __propget_*
accessors; the public name property this branch adds pushed main's already tight graph
over the limit. Property lists now skip the private __* slots a builtin Reflection class
declares (user __* properties are still listed), and method lists skip the
__propget_*/__propset_* accessors, as PHP shows neither. The same listing rule is
shared with static getProperties(). The reproducer's peak heap drops from 8.49 MB to
5.55 MB and the four var_dump_object_tests::reflection_subclass_* tests run again. The
eager design itself is still filed as a separate issue (item 4 below).

Bugs that reproduce on main alone

Found while merging main; they reproduce with fc977b099 alone, so they go as separate
issues (drafts ready):

  1. is_object() does not narrow, so with (array) cast fails EIR lowering, and the checker types it inconsistently (Array(Int) vs Array(Mixed)) #707's (array) typing the valid
    if (is_object($d)) { $i = (array) $d; } else { $i = $d; } over array|object fails
    with "cannot reassign".
  2. An untyped parameter of a method that is never called keeps the checker's int
    placeholder, so array_map(strtoupper(...), (array) $p) fails with "callback parameter
    $s expects string, got int" (before (array) cast fails EIR lowering, and the checker types it inconsistently (Array(Int) vs Array(Mixed)) #707 the cast was array<mixed>).
  3. tests/lang/foreachLoop.013 (by-reference foreach plus array_unshift inside eval)
    crashes with SIGSEGV, and Zend/tests/gc/gc_049 exhausts the heap.
  4. Reflection metadata is built eagerly: two new ReflectionClass('Mine') over
    class Mine extends ReflectionClass, or reflecting class X extends ArrayObject,
    exhaust the 8 MiB default heap.

Laravel compatibility

The motivation is general PHP compatibility, but several of these fixes were found by
compiling Laravel 13 and Symfony vendor code, used as a stress corpus for the parser and
the checker:

  • function_exists() polyfill folding (topic 26): the if (! function_exists('f'))
    idiom of helpers.php files.
  • Dynamic-include manifest: Filesystem::getRequire, config/route/event caches,
    CarbonPeriod's version_compare condition.
  • Checker convergence on large codebases (topics 22 and 27): the outer fixpoint used to
    oscillate (Request::is(...$patterns), CarbonInterval::__callStatic,
    HttpResponseException::$response) and the checker spent most of its time in quadratic
    scans.
  • Nested writes with non-literal keys (topic 28): Symfony's
    EventDispatcher::addListener ($this->listeners[$eventName][$priority][] = $listener).
  • value() / tap() / with() widening (topic 27): untyped helper parameters that
    receive closures and scalars, and callable|null defaults.
  • Static references and static locals (topic 30): Symfony DebugClassLoader
    (self::$darwinCache[$dir] = &self::$darwinCache[$k]) and LocaleFallbackProvider
    (static $p; $p ??= require F;).
  • Others: $this['env'] = v in Application::detectEnvironment (topic 15), defer()'s
    call()[] = $v (topic 12), laravel/prompts keyword namespaces (topic 4), Carbon's
    $instance::setLastErrors() (topic 12), Guzzle/Symfony polyfill statement forms
    (topic 3), enum_value() over joined enums (topic 1), the serializable-closure
    Native::mapPointers references (topic 25).

Current state: --check over a Laravel 13 application converges in 2 outer rounds and
7 method passes (≈45 s, 3.7 GB RSS) and reports 14 652 remaining errors. By family:

Family Errors
Undefined class (optional dependencies outside the autoload closure, conditional classes) 4 408
Signature / return / argument type mismatches 3 282
Expected array 1 640
Constants 795
Conditional helpers called from a namespace (fallback to the global polyfill) 791
Expected object 730
Undefined method / property 633
Literal class names 482
Other (incl. ~100 "static local passed by reference" in Guzzle's CurlFactory) 444
Missing builtins 396
Reassignment with a different type 387
Undefined variable 223
Smaller families 441

How it was validated

  • Every new codegen test's expected output was produced by php 8.5 from the same source.
  • php-src .phpt suite (10 009 tests, php 8.5.10 as reference, run-tests' comparator):
    1752 → 2120 passing tests, main (f96ee9e4a) vs this branch after the merge.
    Before the merge the branch had 2109 (main at fc977b099: 1726). Against that run the
    merge gains 16 tests and loses 5, none of them from this branch's code:
    attributes/override/properties_19 and gh18833 fail the same way on main alone;
    inheritance/bug71248 now reaches an eval() failure that main alone also hits
    (an eval'd subclass that changes an inherited constructor's signature);
    instantiate_all_classes crashed on main's internal WeakMap iterator and is fixed by
    5b263e3; file/bug75679 is a path-length artefact of the run directory and passes
    on its own.
  • 424 behavioral probe programs (php 8.5 expected output): 62 pass on main
    (fc977b099), 418 on the branch
    , unchanged by the merge. The 6 that fail are
    documented limitations.
  • Real-world vendor code (Symfony, Laravel, Guzzle, Carbon) was used as a stress corpus
    for the parser and the checker.

How to test

cargo build                                    # warning-free with the pinned 1.98 toolchain
cargo test --test error_tests
cargo test --test parser_tests
cargo test --test lexer_tests
cargo test --workspace --lib --no-fail-fast
cargo test --test codegen_tests -- checker_converge:: static_ref:: reference_ nested_string_keys:: \
  cond_decl:: dyn_member:: override_compatibility:: stdlib_builtins:: dynamic_include::

Local results on macOS aarch64 after the merge and the reflection fix: error_tests 1934,
parser_tests 498, lexer_tests 234, lib 5248 (0 failed, 18 ignored), var_dump_object_tests
64 (0 ignored), and the codegen suites of every touched area green (reflection, oop::,
class, Core classes, BcMath Number, nested writes, serialize). Builtins docs regenerate
with no drift, and the curl-feature contract and image-stub checks of CI pass.

AI assistance

This work was written with substantial help from Claude Code (Anthropic). Each topic went
through review rounds against php 8.5 output, the php-src .phpt suite and the probe
families above, and I reviewed the changes and remain responsible for them.

🤖 Generated with Claude Code

Restores compilation of interface methods with @template, keeps
interface conformance and override dispatch for templated methods,
demotes broken docblock covariance markers, erases docblock type
arguments on undeclared (builtin) interfaces, and adds the
ELEPHC_PHPDOC_GENERICS=off escape hatch. Regression from illegalstudio#1221.
- Leave an empty marker interface in each class/interface template's
  place and make every instantiation inherit it, so `instanceof Coll`
  holds for every instantiation and subclass; an instanceof guard on a
  template does not narrow to the member-less marker.
- A method template bounded by the native class (`T of Model` on
  `Model`) refines it; a template no position adopts is dropped; a
  native `mixed` is no longer replaced by PHPDoc.
- Erased `array<mixed, mixed>`/`mixed[]` become plain `array`, keeping
  the physical signature of a dispatched method compatible.
- Methods implementing built-in interfaces (ArrayAccess, Countable,
  IteratorAggregate, JsonSerializable, ...) count as dispatched; any
  other undeclared ancestor makes every method dispatched.
- ELEPHC_PHPDOC_GENERICS accepts off|0|false in any case.
- InterfaceInfo::reflection_hidden marks the marker interface a generic
  CLASS template leaves behind; interface_exists, get_declared_interfaces
  and class_implements skip it, and is_subclass_of(x, 'Box') holds only
  for a class extending an instantiation, as in php-src. instanceof is
  unchanged; interface-template markers stay visible.
- An instanceof guard on a template keeps an object local's class and
  reads anything else as mixed, so a store under the guard merges as
  under any other instanceof; the mixed-storage scan no longer treats
  it as a narrowing.
… dispatch ABI

- overrides may make a parent-required parameter optional and rename the variadic
- union returns are covariant member by member, including the class under construction
  (Node|bool -> Leaf|false), guarded by an unchanged physical return representation
- #[\ReturnTypeWillChange] relaxes only internal parents/interfaces, and only when the
  relaxed return keeps the parent's return ABI (or an interface boxes into mixed)
- untyped __toString() gets PHP's implicit ': string' where Stringable or a declared
  parent __toString() requires it
- trailing optional parameters added by an override are refused with a dedicated
  diagnostic: fixed-arity dispatch cannot supply the added defaults yet
A vtable call is typed and compiled against the receiver's declared class but
runs whichever override the object carries. An untyped abstract method took
the LAST child's inferred return (propagate_abstract_return_types), and
untyped overrides boxed params/returns as Mixed while the slot said int, so
`$shape->tag(1)` printed a heap pointer.

- types::override_families groups classes sharing a dispatched method.
- Checker: placeholders take the type all implementations agree on; when
  they disagree, inferred returns take a covering union or mixed, inside the
  method fixed point so callers re-infer from it.
- EIR: every family shares parameter and return representations (Mixed where
  they differ); declared-float/by-ref/generator widenings are refused.
- Late-static result typing never overrides a boxed call ABI.
…prop)

The parser desugars $expr::NAME / $expr::$prop into the internal
__elephc_dynamic_class_constant / __elephc_dynamic_static_property primitives;
ir_lower expands them into a case-insensitive runtime class-name dispatch over
the AOT candidates, each arm lowering the ordinary named node. $this::X maps to
static::X. Dynamic static-property writes and computed $class::{expr} fetches
are rejected with a clear compile error. Reachability treats a non-literal
receiver as a dynamic-class hazard; --strict-php accepts the desugared call.
A statement that failed after consuming its closing brace or semicolon used to
force-skip one more token (the parent's '}' or the next statement's keyword),
desynchronizing the rest of the file; a failure mid array literal resumed inside
the literal. recover_after_failed_stmt skips dangling catch/finally/else clauses,
keeps clean boundaries, and seeds the bracket depth from the failed statement.
…ditions

- #[Attr] const X = 1; (PHP 8.5) attaches the attributes to the ConstDecl;
  a const list keeps PHP's 'Cannot apply attributes to multiple constants
  at once' error.
- while ([$a, $b] = expr) lowers to while (true) with a head that binds
  the value, unpacks it (or nulls every target when it is not an array or
  object, as PHP does) and breaks when the value is falsy.
…perator

PHP accepts any expression as a statement, e.g. '0 > $T && $T += 0x40;'
(symfony/polyfill-intl-normalizer). Literal, predefined/magic constant and
prefix-operator tokens at statement position now go to the expression
statement parser instead of 'Unexpected token at statement position'.
…s exist

- class overrides must keep every parameter's physical representation; an untyped
  parameter cannot replace a declared one (typed later from call sites)
- renamed variadics must keep the element representation and stay contravariant
- overrides and implementations cannot change an inherited default value (callers
  fill the static type's default)
- #[\ReturnTypeWillChange] on a class override always needs a declared return with
  the parent's representation
- the implicit __toString(): string is not applied when a subclass overrides it
  (string conversion dispatches statically)
self::$x = require 'f.php'; (and static::/Named::, and [] appends) binds the
include to a hidden local the resolver can expand, then stores it in the
property. ??=, compound operators and dimension targets with an include
value fail at compile time with a clear diagnostic instead of
'Unexpected token: Require'.
…tions

preg_quote, strspn, strcspn, strrchr, strip_tags, substr_compare,
get_debug_type, http_build_query, version_compare, random_bytes, pack and
unpack, as PHP functions in a pay-for-use prelude (src/stdlib_prelude), the
PHP_QUERY_RFC1738/3986 constants, and a foldable function_exists() for
these functions (guarded vendor polyfills).
__rt_mixed_array_get only understands containers and ArrayAccess objects, so
$mixed[$i] on a boxed string (e.g. f()[0] with f(): mixed, monolog's
$client::VERSION[0]) silently answered an empty string. Boxed receivers that
may hold a string now split at runtime: a string payload is cast, read with
StrCharAt (warnings preserved, ?? sees null on a silent miss) and the
character persisted before the cast string is released; everything else keeps
the boxed container path.
…ble, SessionHandlerInterface, ReflectionType

- Every enum implements UnitEnum (backed: BackedEnum); enum interface lists are now
  transitive (enum implementing B extends A satisfies an A parameter).
- Serializable / SessionHandlerInterface checker interfaces (the --web prelude's
  declaration wins); legacy Serializable without __serialize is rejected instead of
  silently omitting PHP's Deprecated notice; non-enum classes cannot implement UnitEnum.
- natives_prelude: WeakMap (strong keys, documented) and ErrorException (severity;
  non-null filename/line rejected at compile time), injected post-autoload.
- ReflectionType abstract parent of the Reflection type classes.
- Catalog entries for all of the above.
…for now

- a mixed receiver omitting the argument found no dispatch candidate and threw a
  catchable Error, so making a parent-required parameter optional is refused
- by-reference parameters follow the identical-representation rule
- class-relative defaults (self::/static::/parent::) count as changed defaults
'static $a = 1, $b = 2;' (symfony/polyfill-uuid Uuid.php:432) declares one
StaticVar per declarator instead of failing with "Expected ';'".
$obj::staticMethod() desugared to call_user_func([$obj, 'm']), whose runtime
callable-array dispatch only finds instance methods, so it died with
'callable array did not resolve to an invokable target'. A literal method name
now desugars to __elephc_dynamic_static_call($recv, 'm', ...$args), expanded in
ir_lower like $class::X: one runtime class-name match per class declaring m,
each arm lowering C::m(...) (late static binding intact) or, for an instance
method, $this->m(...) when $this is a C and PHP's 'cannot be called statically'
Error otherwise. Unknown methods throw 'Call to undefined method'. A candidate
with by-reference parameters is refused at compile time. Reachability keeps the
method; --strict-php accepts the desugar. $cls::$m() keeps call_user_func.
Prelude injected after the autoload, http_build_query, substr_compare,
random_bytes without fallback, get_debug_type, strrchr, version_compare,
preg_quote and strip_tags.
…, cache groups

Follow-ups to the override-family call ABI:
- A body whose declared int/float/string/bool parameter or return was boxed
  into the family's Mixed ABI re-applies the declared coercion itself: on
  entry for parameters (int with PHP's TypeError, reusing
  ReturnBoundaryMixedToInt with an "Argument #n ... given" message) and
  before boxing its result. The declared-float refusal is gone.
- A family whose members differ in arity, by-reference or variadic slots
  is refused when its ABIs diverge, instead of being skipped.
- Checker: an inferred object return is never widened, so callers are not
  typed more strictly than before; placeholders take the family fold.
- Family groups are computed once; widening starts before function and
  method inference and is dropped once the bodies agree again.
…enum marker checks)

- ErrorException is injected only when named; parent::/static calls register the
  builtin Throwable constructor as reachable (introspection-only subclasses linked
  against an undefined Exception::__construct).
- WeakMap iterates the live insertion order; serialize/unserialize throw like PHP;
  a null key is the TypeError.
- Enums: reject re-implementing UnitEnum/BackedEnum and pure enums reaching
  BackedEnum; class C implements BackedEnum names BackedEnum.
- Plain diagnostic for ErrorException filename/line; redeclared builtin
  interfaces carry the user's location.
…ing representation

- a default spelled self::X/parent::X on both sides is unchanged when both resolve to the
  same constant value (`handle(..., $type = self::MAIN_REQUEST)`); static:: and a
  redefined constant still count as changed
- a class override whose union-covariant return changes the physical representation of
  the parent's return (int|null or false under a boxed union, static under static|false)
  is refused for now; the former codegen test for static|false -> static becomes an
  error test (it miscompiled through the parent)
…erences

- `call()[] = $v` in expression position (e.g. in an arrow fn) is an
  immediately invoked static closure: an ArrayAccess result gets
  offsetSet(null, $v), an array result is a discarded temporary, a string
  throws PHP's Error; the expression yields $v. Appends to a variable or
  property in expression position are rejected with a clear message.
- `new static::$prop(...)`, `new C::$prop`, `new $obj->prop(...)`,
  `new $a['k']` and their chains build NewDynamic from the class-name
  reference, as PHP's new_variable grammar does.
- `$callback(...)` reports a targeted error: elephc has no runtime
  Closure::fromCallable, and a wrapper closure would answer reflection
  differently.
@github-actions github-actions Bot added size:xl Very large pull request that needs deliberate review planning. target:linux-x86_64 Contains behavior specific to the Linux x86_64 target. type:feature Introduces new user-visible behavior or capabilities. labels Oct 18, 2026
Conflict resolution keeps both sides:
- builtin contract catalogs: union of both sides' entries in catalog
  order; registry/support counts add main's fourteen __elephc_bcnumber_*
  helpers to this branch's additions.
- ir_lower/function.rs: maybe_undefined frame flags initialize before
  main's native Core method bodies and the ordinary body.
- ir_lower/stmt/nested_array_writes.rs: main's rooted write operands with
  this branch's early bare-variable read and container acquire.
- ir_lower/expr/{assignments,property_access}.rs: main's WeakMap paths
  ahead of this branch's reference-literal and transient-receiver paths.
- checker schema: main's Serializable tentative returns and WeakReference/
  unserialize signatures on top of this branch's ReturnTypeWillChange
  handling, class-scoped method signatures and enum interface closure.
- pipeline.rs: main's core and bcmath preludes next to the autoloaded
  hash prelude.
- unary plus and builtin SPL static-call discovery were added on both
  sides; one arm each is kept (unary plus keeps php-src's `expr * 1`).
- generated builtin docs and registries are taken from main here and
  regenerated in a follow-up commit.
main now declares WeakMap, WeakReference and ErrorException in
core_prelude, and UnitEnum, BackedEnum and Serializable in
builtin_interfaces. Both sides injecting them made every program that
names WeakMap fail with "Cannot declare class WeakMap" and every
compile fail with "Cannot redeclare built-in interface: UnitEnum".

- Drop natives_prelude (WeakMap, ErrorException) and its catalog marker
  interface: main's classes are weak and accept a caller-supplied file
  and line, so the filename/line refusal goes with it.
- builtin_native_interfaces keeps SessionHandlerInterface and the
  non-enum UnitEnum/BackedEnum check; the legacy Serializable refusal
  goes, since main implements the C: envelope.
- main's redeclaration check reports the user's declaration span.
- The enum misuse test expects PHP 8.5's message ("Enum Bad cannot
  implement previously implemented interface UnitEnum").
Output of the builtins-docs-sync generators (extract_builtins.py --render
--force, gen_module_sections.py, gen_php_comparison.py) after merging
origin/main: main's BcMath Number helpers and Core classes/constants
next to this branch's internal builtins. No hand edits.
…ip test

With main's Core class declarations this program also retains the declared-return
coercion helper, whose TypeError messages call get_class() on a concrete object. The
test is about main's two eval-bridged Mixed operands, so it counts those; every
lookup still has to release its result.
…ses()

main's WeakMap prelude declares __ElephcWeakMapIterator as a program statement, so the
source-order half of the declared-class list exposed it. Instantiating every declared
class (php-src Zend/tests/instantiate_all_classes.phpt) then reached the internal
iterator without its map and crashed with SIGSEGV. The source-order names now go
through the same internal-name filter as the registered ones.
…sors

Reflecting a user subclass of ReflectionClass (`class Mine extends
ReflectionClass {}` then `new ReflectionClass('Mine')`) exhausted the
default 8 MiB heap.

`new ReflectionClass(<name>)` materializes its metadata graph eagerly and
every ReflectionMethod embeds a declaring-class ReflectionClass. Each of
those nested reflectors of ReflectionClass listed the ~37 private `__*`
backing slots elephc declares on the builtin Reflection classes as
properties and default properties, which put the graph right at the heap
limit. Adding the public `name` property (with its `__propget_name`
accessor) and the `__name_only` slot to ReflectionClass pushed it over.

PHP's Reflection classes declare no private properties, and property-hook
accessors are not PHP methods, so neither should be reflected:

- reflection property lists skip private `__*` slots declared by a builtin
  Reflection class (user-declared `__*` properties are still listed);
- method lists (`hasMethod()`, `getMethods()`, the eager `__methods`
  metadata) skip `__propget_*` / `__propset_*` accessors, resolved on the
  declaring class.

Peak heap for the reproducer drops from 8.49 MB to 5.55 MB. The four
`reflection_subclass_*` var_dump tests run again, and new codegen tests
cover the minimal reproducer and both filters against PHP 8.5 output.
…rties()

`getProperties()` on a receiver known at lowering time (`$rc = new
ReflectionClass('X')` in the same scope, or inline) is expanded statically
by `reflection_class_property_names_for_filter`, which applied no
visibility rule. It still listed the private `__*` storage slots of the
builtin Reflection classes while the runtime metadata path hid them, so
`getProperties()[0]->getName()` returned `__name` instead of `name` for a
ReflectionClass subclass, and it listed a parent's private properties,
which PHP never reports.

Move the rule into `ClassInfo::reflection_lists_property` and use it from
both the codegen metadata (`reflection_property_visible_from_class`) and
the static EIR expansion (list and `getProperties()[N]` index forms), with
one row per name as in the codegen path.

Narrow the docs claim to compiled code: reflection inside eval() fragments
and method_exists() do not apply these filters yet.
main's larger Core class set raised the outer round budget, so the oscillation
detector now saw what the smaller budget used to cut off: a CallbackFilterIterator
built with a runtime callable array widens the pinned $callback property to an
array in the top-level pass, and patch_builtin_spl_storage_signatures pins it back
to callable before every method pass. The three
spl::decorators::test_callback_filter_iterator_runtime_selected_* tests failed with
"outer fixpoint does not converge".

Stabilization and the detector now compare the round's classes after the patch,
which is what the next round reads; the classes the top level left are kept, as
before.
@nahime0

nahime0 commented Oct 19, 2026

Copy link
Copy Markdown
Member

Thanks for the batch and for the detailed write-up in #1887. Before reviewing the code, we cross-checked every topic of this branch against main, merged PRs, open issues and open PRs. We also ran representative snippets from your tests on a main nightly, on this branch and on php 8.5.11.

The good news: almost every topic fixes behavior that main really gets wrong, and the branch matches php on the probes. But a 49k-line PR cannot be reviewed or merged as one unit. Some parts are already on main, several duplicate open PRs that came first, and the branch fixes about 30 open issues without citing them. We would like it split as described below.

Rules for every split PR

  • One topic per PR, based on current main (it moved again today: fix(parser): align compound append diagnostics with PHP #1700 and fix(properties): enforce and reflect asymmetric set visibility #1678 were merged).
  • Closes #N for every issue the PR fixes (the issue numbers are listed per PR below).
  • A regression test that fails on main for every fix. Some topics include tests that already pass on main; those do not count as regression tests.
  • Where an open PR already covers part of a topic, build on top of it once it lands instead of reimplementing it. If you think the other PR is wrong or incomplete, a review comment there is the best place to say so.
  • Bugs that have no issue yet: open one, or put the repro in the PR body.

1. Already on main: please drop these parts

Part of this branch Already on main via Notes
Implicit UnitEnum/BackedEnum on enums and the transitive enum interface closure (topic 8) #1870 The branch's own non-enum check duplicates main's: class C implements UnitEnum now reports "Non-enum class C cannot implement interface UnitEnum" twice.
Unary + (topic 19) #1869 (85ac9b9) If the expr * 1 lowering fixes a real difference (-0.0, the array operand error), send it as a tiny separate PR with a test that fails on main, rather than changing main's existing test.
Nullable-object operands of ?:/?? merges (topic 14, 106cd89) #1837 (fixed #1628) After the merge, both mechanisms live in ir_lower merge_temps.rs; only one should remain.
Append assignment expressions returning their value on local, nested, property and static-property receivers (parts of topics 12, 15, 21) #1700, merged today (closes #1498) #1700 also adds the ExprKind::Assignment arm to name_resolver::resolve_expr that topic 26 adds.
static|false overridden by static, covariant self returns (parts of topic 13) #578, #473
Declared : int returned from Mixed/Float values (part of topic 16) 5a2ffe7 The other scalar types and strict_types are new.
Topic 27: 7 of its 14 codegen tests main Class joins (siblings, parent/child, interface-typed constructor property), the identity helpers and with() already match php on main. Only the other 7 are regressions on main: value(), tap(), data_get, untyped variadic over mixed scalars, __callStatic int/float, untyped constructor property, implicit Stringable.

2. Covered by open PRs that came first

These PRs predate this branch and implement the same feature. They take precedence: please drop the matching part here, and add any extra behavior on top of them once they land.

Feature in this branch Open PR Issue Notes
Inline HTML, ?>, <?= (topic 5) #1788 #839 It creates the same src/lexer/inline_html.rs. #1788 also covers the eval lexer, shebang lines, .lfc files and a leading declare.
Reserved keywords as qualified-name segments (topic 4) #1370 #826, #840 #1370 also covers the eval lexer and parser. If bare \self/\parent/\static handling is still missing after #1370, it can be a small follow-up. Relative names (namespace\f(), #825) are in #1369; this branch does not fix them.
http_build_query (topic 11) #1781 #858 (part) Includes the eval side and the builtin contract.
strip_tags (topic 11) #901 Native AOT and eval implementation.
Append lvalues: f()[] = v, $this[] = v, ArrayAccess appends through offsetSet(null, v), appends in the middle of a write chain (topics 12, 15, 21) #1385 #845 Same files, same approach. #1385 currently refuses $a[] = &$x, which topic 25 implements; that goes into the references PR (D2) on top of #1385. Please check #1478 and #1497 against #1385 too.
Prefix ++/-- on static array elements, ++self::$x[$i] (topic 24) #1710 #1602
Trait __CLASS__ rebinding in constants (topic 24) #1674 #1317 Same magic_constants files. The compiler panic on __CLASS__ inside a class constant is new and stays in PR 16 below.
unset() of array elements held in properties and static properties (topics 28, 30) #1386 #750 #1386 is narrower: it refuses untyped static arrays, which this branch supports. Extend on top of it.
Ternary over two static array properties (topic 14) #1546 #1501
?int ?? null, ?int truthiness, "Return value must be of type X, null returned" (topics 14, 16) #1854 #1835
var_dump & marker on live references (topics 20, 29) #1831 #1802 Same runtime files, different mechanism.
Reflection name/class public properties (topic 18) #1297 #1283 #1297 models them as stored properties; topic 18 uses a get hook. Please align with #1297.
Evaluation order of nested element writes (the limit topic 28 documents) #1844 #1653 Same file topic 28 rewrites.
#[\ReturnTypeWillChange], overrides that add optional parameters (topic 13) #1806 #1806 adds has_return_type_will_change (same name, different signature, same file). It also allows optional-parameter overrides without an ABI change, which topic 13 refuses. To be reconciled in D1.

The following overlap only at file level, so they mean rebasing, not dropping code: #638, #1528 (moves compilation behind elephc build, which affects the new CLI flags and docs), #1703, #1859, #1387, #1754, #1771.

3. The PRs we would like

Independent PRs (no open design question)

  1. Statement forms (topic 3, without the include into a static property, which topic 19 supersedes):

    • attributes on const, destructuring in while;
    • statements led by a literal, constant or prefix operator;
    • static $a, $b lists, parser recovery.

    Closes the while half of Support destructuring assignments in conditions and property foreach targets #847. Note: #[\Deprecated] const is accepted, but php's deprecation warning is not emitted.

  2. include/require as expressions (topic 19, plus the include into a static property from topic 3): call arguments, operands, ??=, ternaries, $f::new(), new (expr). Closes Allow literal include and require in nested and property-assignment expressions #852.

  3. Dynamic class access and new forms (topic 12 without the appends):

    • $class::X, $obj::$prop, $obj::m();
    • new static::$p[..], new $o->p, new $a['k'];
    • string offsets read from mixed.

    Closes Support dynamic new class-name expressions from property and array access #850 and Support AOT static-property reads on a variable class receiver #1455. Main currently fatals at runtime on $obj::staticMethod() and returns "" for a string offset on mixed; neither has an issue yet.

  4. Dynamic members and calls (topic 21 without the appends, plus self::${$p} / static::$$x from topic 24): static::{$n}(), self::$m(), [$a, 'm'](), named arguments in dynamic calls, property foreach targets. Closes Support dynamic static method, property and constant member names #844 (together with PR 3), the foreach half of Support destructuring assignments in conditions and property foreach targets #847, and part of ir_lower: call_user_func path rejects named args plus a remaining spread #1146.

  5. Assignments as expressions (topic 15 parser part, minus what fix(parser): align compound append diagnostics with PHP #1700 and fix(parser): accept append lvalues in the middle of writes, in expressions, on $this and on call results #1385 cover): a ?: $o->p = v, !$x = f(), $x = &$y as an operand, definitions made inside short-circuit conditions. Closes Remaining assignment-expression gaps: compound-condition scope and reference assignment #853 (together with D2); partially fixes Parse variable-led logical/ternary statements with array/property assigns #1451 and Closure returns that are not a single call expression still use the Int syntactic fallback #1583.

  6. Possibly-undefined locals (7492072). Main returns stale values or garbage where php gives null plus "Undefined variable". Needs an issue.

  7. match (true) arms narrowed by instanceof (topic 1). Main silently returns wrong values. Needs an issue.

  8. Widening through chained calls (topic 6). Closes Untyped method returning a ternary over $this fails with 'Undefined class: ' (empty name) #1547.

  9. Generics follow-ups (topic 10). Since the generics monomorphization (feat(generics): monomorphize generics and typed array storage #1221), main rejects 15 of your 17 plain-PHP @template cases and miscompiles the other 2. Needs an issue that references feat(generics): monomorphize generics and typed array storage #1221.

  10. Checker driver (topic 22):

    • indexes instead of the quadratic scans;
    • monotonic method variadics, post-autoload preludes;
    • file-qualified file:line:col diagnostics. This format changes for every diagnostic, so please call it out in the PR.

    Closes Give autoloaded files distinct span identities #1445 and the autoload part of Give Span a file identity so cross-file same-position collisions stop being compile errors #778.

  11. Conditional declarations and enum interface members (topic 26, without the name-resolver Assignment arm, now on main via fix(parser): align compound append diagnostics with PHP #1700):

    • function_exists() polyfill folding;
    • use imports inside yield/clone/new $c;
    • ->name/->value through UnitEnum/BackedEnum.

    Closes Register functions declared inside function_exists guards #842 and Accessing BackedEnum::$value after instanceof BackedEnum reports an undefined class #832. Note that any top-level eval() disables the folding.

  12. Merge typing (topic 14, after fix(ir_lower): type static-property and call arms of a branch merge from their declarations #1546 and fix(types): preserve nullable scalar coalesce results #1854): typing merge temps from lowered values, int/bool merges, a catchable UnhandledMatchError with php's message. Closes Branch-merge typing prefers the syntactic builtin allowlist over the EIR call result #1633.

  13. Runtime coercion of declared scalar returns (topic 16, after fix(types): preserve nullable scalar coalesce results #1854). The TypeError currently reports a line inside the prelude helper (100) instead of the user's line.

  14. First-class callables from runtime values (topic 18): $cb(...), Closure::fromCallable($x), and ReflectionFunction over them, with Reflection name aligned with fix(reflection): expose PHP public properties across object views #1297. Closes Parser rejects dynamic first-class callable syntax ($callable(...)) #828 and Parser: support first-class callable syntax on callable-valued variables #864.

  15. Introspection fixes:

    Note: php 8.5 var_export does render virtual hooked properties (with their hook values). The commit message is wrong for var_export; main and the branch both omit them.

  16. Static member fixes (the rest of topic 24). These are serious main bugs, none of them has an issue, so please open them:

    • the compiler panic on __CLASS__ inside a class constant;
    • a returned static property being freed;
    • a null-defaulted untyped static that later holds an array (SIGSEGV);
    • string keys written into an untyped static [];
    • ??= require on static places.
  17. Nested writes with non-literal keys (topic 28, after Preserve PHP array element write evaluation order #1844 and fix(unset): remove array elements held in object and static properties #1386). Closes Nested append into a missing element fails with "isset array offset for PHP index type Str/Mixed" #1359 and the rest of unset() on a typed array property element fails EIR lowering — blocks full ArrayAccess offsetUnset implementations #750. On the listener-map case, the branch still prints "Undefined array key" warnings on stderr that php does not print; the tests only check stdout.

  18. File helper failure paths (topics 2 and 7):

    • fwrite returning int(0) on EAGAIN;
    • failures in file_put_contents, file_get_contents, copy, fputcsv, scandir and hash_file.

    Coordinate fputcsv with feat(spl): php-src compliance + php-compatible runtime diagnostics #1806, which rewrites __rt_fputcsv and the contract arity, and the runtime files with feat(core): finalize PHP streams implementation #638. The warnings should carry php's full message (path and reason).

  19. Missing builtins (topic 11 without http_build_query and strip_tags): preg_quote, strspn, strcspn, strrchr, substr_compare, get_debug_type, version_compare, random_bytes, pack, unpack. Closes Implement cryptographically secure random_bytes #862 and Implement pack and unpack binary conversion builtins #863, and part of Missing string and version builtins: preg_quote, version_compare, uniqid and stristr #859.

    Design point 4 (prelude or native) still needs an answer, but either way these must be real builtins. Today they are plain user functions in the prelude, which means:

    • eval() cannot call them unless the program names them;
    • ReflectionFunction::isInternal() returns false, and get_defined_functions() lists them under user;
    • they have no builtin docs pages;
    • their warnings are reported against the user's file.

    version_compare should exist only once; it is also reimplemented in resolver/dynamic_includes/version_fold.rs. Keep the random_u32 change (an abort instead of the time() fallback) out of this PR; php throws RandomException instead.

Design PRs (agreement first, one at a time)

These change internal contracts or reverse earlier decisions. Please send each one separately, with its own issue or discussion first, so the design can be reviewed in isolation.

4. The main-only bugs you planned to file

None of the four has an issue yet, so go ahead. Related issues to link:

Suggested order

  1. The small independent PRs: 1 to 4, 7 to 11, 15, 18, 19.
  2. The ones that depend on open PRs: 5, 12, 13, 14, 16, 17.
  3. The design PRs, one at a time.

This draft can stay open as the reference while the split PRs go up.

@JGamboa

JGamboa commented Oct 19, 2026

Copy link
Copy Markdown
Author

Thanks a lot for the thorough cross-check. We followed the split you proposed. The first wave of independent PRs is up. Each one is based on current main, carries Closes #N for the issues you listed, and only counts regression tests that fail on main. Tests that already pass on main are kept as marked controls.

Your # PR Closes
1 #1914 statement forms part of #847 (while)
2 #1915 include/require as expressions #852
3 #1916 dynamic class access and new forms #850, #1455, #1896, #1897
4 #1917 dynamic members and calls (stacked on #1916) #844 (with #1916), part of #847 (foreach) and #1146
7 #1918 match (true) narrowed by instanceof #1898
8 #1919 widening through chained calls #1547
9 #1920 generics follow-ups #1899
10 #1921 checker driver (changes the diagnostic format, called out in the PR) #1445, part of #778
11 #1922 conditional declarations and enum interface members #842, #832
15 #1923 introspection fixes #1900, part of #855
19 #1924 strspn/strcspn/strrchr/substr_compare/preg_quote, and #1925 version_compare/random_bytes. Both are real builtins (contract, runtime, eval), not prelude code. part of #859, #862

We also opened the main-only bugs you mentioned: #1892, #1893, #1894 and #1895, plus the ones the PRs needed (#1896 to #1900).

Not in this wave:

Known limitations and open questions are written in each PR body. For example, #1916 asks whether you want php's exact Class "X" not found message, which needs a per-site check. We'll keep this draft open as the reference, as you suggested.

🤖 Generated with Claude Code

@JGamboa

JGamboa commented Oct 20, 2026

Copy link
Copy Markdown
Author

Second batch, still following your split.

New PRs:

New issues:

Still waiting on the open PRs they build on: 5, 12, 13, 14, 16 and 17.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:codegen Touches target-aware assembly or backend lowering. area:eir Touches EIR definitions, lowering, validation, or passes. area:types Touches type checking, inference, or compatibility. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:xl Very large pull request that needs deliberate review planning. target:linux-x86_64 Contains behavior specific to the Linux x86_64 target. type:feature Introduces new user-visible behavior or capabilities.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants