Source: src/types/ — mod.rs, model.rs, signatures.rs, result.rs, schema.rs, traits.rs, fibers.rs, checker/mod.rs, checker/driver/, checker/builtin_interfaces.rs, checker/builtin_iterators.rs, checker/builtin_json.rs, checker/builtin_spl_exceptions.rs, checker/builtin_spl_classes.rs, checker/builtin_spl_classes/, checker/builtin_class_gate.rs, checker/builtin_stdclass.rs, checker/builtin_types/, checker/builtins/, checker/functions.rs, checker/functions/, checker/inference/, checker/mixed_storage_scan.rs, checker/binding_decision_ambiguity.rs, checker/stmt_check.rs, checker/stmt_check/, checker/type_compat.rs, checker/type_compat/, checker/schema/, checker/yield_validation/, warnings/
PHP is dynamically typed — variables can change type at runtime. But elephc compiles to native code where every value must have a known size and location. The type checker bridges this gap by inferring types at compile time.
Why type checking matters
The code generator needs to know types to emit correct assembly:
- An
Intlives in registerx0(8 bytes) - A
Floatlives in registerd0(8 bytes) - A
Stringlives inx1(pointer) +x2(length) = 16 bytes
If the code generator doesn’t know whether $x is an integer or a string, it doesn’t know which registers to use, how many bytes to allocate on the stack, or which comparison instruction to emit (cmp for integers vs fcmp for floats).
The type system
File: src/types/model.rs
elephc has a small type system:
pub enum PhpType {
Int,
Float,
Str,
Bool,
False, // literal `false` subtype; runtime representation identical to Bool
Void, // null
Never, // marks a function/method that never returns (always throws / exits / loops)
Iterable, // PHP `iterable` pseudo-type (array | Traversable), type-erased
Mixed, // runtime-boxed heterogeneous array / user mixed value
Array(Box<PhpType>), // e.g., Array(Int) = int[]
AssocArray { // e.g., AssocArray { key: Str, value: Int }
key: Box<PhpType>,
value: Box<PhpType>,
},
Buffer(Box<PhpType>),
Narrow(ScalarWidth), // storage width of a buffer<T> element only (buffer<i32>, buffer<f32>)
Callable, // closures and function references
Object(String), // class instance, e.g., Object("Point") or Object("App\\Point")
Packed(String),
Pointer(Option<String>), // opaque ptr or pointer tagged with a target type
Resource(Option<String>), // generic resource or typed resource such as resource<stream>
Union(Vec<PhpType>),
TaggedScalar, // codegen-internal inline nullable scalar: {payload, tag} pair
}
This is still much smaller than full PHP’s runtime type system, but it now includes user-written union and nullable annotations where the language subset supports them. Union(...) values are lowered to the same boxed runtime representation used by Mixed. TaggedScalar is never produced by the checker itself: codegen funnels construct it from int|null unions under the tagged null representation (the default; see --null-repr), storing the value as an inline two-word {payload, tag} pair instead of a heap-boxed cell. The distinction between Array (indexed) and AssocArray (key-value) is determined at compile time from the literal syntax ([1, 2] vs ["a" => 1]), and heterogeneous payloads in either representation widen to boxed Mixed elements.
Narrow(width) is the storage width of a buffer<T> element declared with a C width name
(buffer<u8>, buffer<i32>, buffer<f32>). It only ever appears inside Buffer: every
element read and write uses storage_value_type(), the Int or Float the width reads as, so
no value is typed Narrow (the EIR validator rejects one). Packed class fields keep the same
widths in their metadata instead, as PackedFieldInfo::width beside the field’s int or
float type, and PackedClassInfo::align records the natural C alignment the layout pads to.
False is the PHP literal false subtype used by false-sentinel declarations such as int|false (the shape many builtins like strpos return). Its runtime representation is identical to Bool, and bool accepts a False value. Union normalization keeps a distinct False member only when the declaration is false-only: a full bool member in the same union absorbs it.
Never is a return-position-only marker: a function annotated : never must always diverge (throw, call exit()/die(), or loop forever). The type checker rejects any reachable return value; from such a function, and the runtime size is zero because the value is never materialized. : never is rejected as a parameter or local-variable type — same restriction as : void.
Iterable represents PHP’s iterable pseudo-type (array | Traversable). It is treated as a type-erased 8-byte raw heap pointer at runtime — the checker accepts Array, AssocArray, Iterator objects, and IteratorAggregate objects for parameters declared iterable, and foreach over an iterable local types both $key and $value as Mixed. Direct operations on iterable values (foreach, echo, gettype(), var_dump(), ===, scalar casts, is_iterable()) dispatch through the __rt_heap_kind runtime helper. Indexed-array iterables use the value-type tag stored in the array header to box loop values as Mixed; associative iterables reuse the hash iterator payload tag; object-backed iterables branch through interface metadata and then use the Iterator method dispatch path.
Callable is used for anonymous functions (closures), arrow functions, and first-class callables. Closure and first-class callable values are stored as one 8-byte descriptor pointer. The descriptor carries the callable kind, native entry ABI slot, optional PHP-visible name, signature/environment/invocation metadata, and an optional generated invoker slot. Dynamic-call builtins reuse that metadata and generated wrapper cases to invoke runtime-selected user functions, supported builtin string callbacks, public static-method strings, callable arrays, and invokable objects.
Object(String) represents a class instance. The string carries the canonical class name after name resolution (for example "Point" or "App\\Point"). Objects are heap-allocated pointers (8 bytes on the stack).
Pointer(Option<String>) represents a raw 64-bit address. Pointer(None) is an opaque pointer, while Pointer(Some("Point")) is a pointer tagged with a checked pointee type. The tag affects static checking, but the runtime value is still just a native address. A target tag can name a class, a C width, or a nested pointer such as ptr<App\\Node>. C widths use a reserved internal prefix so they cannot collide with a class of the same name; PhpType display removes that prefix. Class-name consumers must unwrap nested targets using names::pointer_target_leaf or pointer_target_leaf_name.
Resource(Option<String>) represents PHP resource handles. Resource(None) is a generic resource, while Resource(Some("stream")) is the stream-handle shape used by successful fopen() calls and the STDIN / STDOUT / STDERR constants. Resource values are stored as one 8-byte native payload in codegen, but the type checker keeps them distinct from integers so stream built-ins can reject plain numeric descriptors.
Generic specialization
src/generics.rs::monomorphize() drives checking to a fixed point: each round
collects requested function, class, constructor, and method instantiations,
substitutes their concrete types, splices declarations into the AST, and checks
again. Generic traits are instantiated before trait flattening. Templates are
removed before downstream optimization and EIR lowering, which receive ordinary
concrete declarations such as Box<int> and identity<int>.
Generated class, interface, and trait declarations precede executable statements.
This keeps specializations available when the program ends with a terminating
statement, including the exit() appended by the native test driver. A generic
class reached only from a test method uses the same specialization path as an
ordinary program.
Included generic functions retain their runtime include dispatch. The checker binds the
public template at each call and specializes every possible include variant before
comparing their concrete signatures. Variants must declare matching type parameter
contracts, including bounds and defaults; parameter names may differ. After the fixed
point, each public specialization has its own dispatcher and activation marks at the
original include positions. This also applies to functions declared in files required
by elephc test.
The rewrite updates both the AST and the checker’s retained class and enum method
bodies, including flattened trait methods, because EIR lowers those retained copies.
The checker validates bounds and variance, and records resolved call, construction,
and method sites in CheckResult. Site keys include the enclosing body scope and
source span, so one template coordinate can select different instantiations.
Typed arrays retain their declared element or key/value storage, and callable
signatures carry inference information while keeping ordinary descriptor storage.
Native storage widths remain in generic bindings and specialization identities.
generics::substitute_value_type() maps a bound width to its PHP scalar carrier
only when materializing a value annotation. The AST walker’s transform_value_type
hook covers parameters, returns, ordinary members, locals, and closure signatures;
buffer allocation elements, nested buffer types, class arguments, and type defaults
retain raw substitution. Checker-created signatures use the same value helper as
the spliced AST, so T = u32 yields an int method ABI over buffer<u32> storage.
Concrete bindings recurse through PHP value wrappers before their annotations resolve;
widths nested inside buffer<T> or class arguments remain storage identities. Bounds
check the same PHP carriers. Written generic call suffixes resolve in caller scope and
participate in the shared AST type walk. Parser-owned suffixes retain all explicit
root qualifiers until resolution; no-op type walks preserve their original spelling.
Resolved suffixes then use canonical specialization names. Rooted width-named classes retain that marker
through binding adaptation and name decoding, so they cannot be confused with bare
storage widths. The dispatch stability proof treats native width slots as scalars,
allowing final accessors to inline without permitting width arguments to widen.
Supported PHPDoc templates are applied per physical file after the strict-PHP audit. They affect type checking and specialization in both modes; strict mode rejects the native generic syntax. See Generics for bounds, defaults, variance, runtime class identities, and current limitations.
How inference works
The type checker walks the AST top-down, maintaining a type environment — a HashMap<String, PhpType> that maps variable names to their types. It also tracks a constants map — a HashMap<String, PhpType> that records the type of each user-defined constant (declared via const or define()).
Assignments create types
$x = 42; // $x: Int (inferred from the literal)
$name = "Alice"; // $name: Str
$pi = 3.14; // $pi: Float
$ok = true; // $ok: Bool
$nothing = null; // $nothing: Void
The first assignment determines a variable’s type. After that, reassignment is only allowed to the same type (with some exceptions, below) — for a declared type (a typed local, a type-hinted parameter, a class property) this is enforced strictly in every mode. For an untyped local, an incompatible reassignment is instead a warning by default; see Local retyping and strict locals mode.
Type compatibility rules
| From | To | Allowed? |
|---|---|---|
Int | Int | Yes |
Int | Float | Yes (numeric types are interchangeable) |
Int | Bool | Yes (numeric/bool interchangeable) |
Int | Str | No for a declared type — compile error. For an untyped LOCAL the default is a warning plus a fresh re-binding, and --strict-locals makes it the error (see Local retyping and strict locals mode) |
False | Bool | Yes (literal false is a subtype of bool) |
Void | anything | Yes (null can become any type) |
| anything | Void | Yes (any variable can become null) |
Array(T) | Array(U) | Yes, if T and U merge; heterogeneous indexed values widen to Array(Mixed) |
AssocArray(_, T) | AssocArray(_, U) | Yes, if T and U merge; heterogeneous values widen to Mixed |
Pointer(None) | Pointer(Some("T")) | Yes (merged to the more specific pointer tag) |
Pointer(Some("A")) | Pointer(Some("B")) | Yes, but merged to opaque Pointer(None) if tags differ |
Pointer(*) | Int / Str / Array | No — compile error |
Resource(None) | Resource(Some("stream")) (or vice versa) | Yes (generic resource accepts typed resources) |
Resource(Some("stream")) | Int | No — stream handles are not plain numeric descriptors |
Array(_) / AssocArray(_, _) / object implementing Iterator or IteratorAggregate | Iterable parameter | Yes (PHP iterable accepts arrays and Traversable objects at the call boundary) |
Declared boundaries are looser than plain reassignment. A Mixed value is accepted where a declared parameter, return, or property expects a plain Int, Float, Bool, or Str (PHP’s coercive mode): the checker lets it through and codegen inserts the runtime unboxing/narrowing conversion. Union values are accepted member-wise — every member of the actual union must be accepted by some member of the expected type.
Local retyping and strict locals mode
Files: checker/mod.rs (local_binding_is_killable, per-body eligibility state), checker/stmt_check/assignments/locals.rs (merge_local_assignment_type), checker/mixed_storage_scan.rs (run_mixed_storage_scan), checker/inference/expr/effects.rs (the unset() kill), checker/binding_decision_ambiguity.rs (span-collision guard)
A declared type — a typed local (int $x = 5;), a type-hinted parameter, or a class property — always enforces the type-compatibility table above strictly, in every mode: retyping one is a compile error.
For an untyped local, DEFAULT (permissive) mode instead allows a local to change type in three shapes that would otherwise be this same error. All three exclude a name that is reference-aliased (no =& target or source, no use (&$x) capture, no by-reference parameter including a variadic &...$xs tail, and neither name a by-reference foreach touches — foreach ($arr as &$v) permanently aliases BOTH $arr, the container the loop holds references into, and $v, which is itself one of those references, since PHP leaves it bound to the last element after the loop and lowering ref-binds its slot for the rest of the body), and a name this body binds with global, a static name, or a superglobal/seeded name, since this body does not own their storage.
Call arguments are the fourth aliasing source, and it is wider than a declared &$p. Where the checker resolves the callee’s signature it records an alias per parameter, at the by-ref slots alone (Checker::record_reference_alias_root). Where it CANNOT — a variable function, a signatureless callable, a dynamically named method, a runtime-selected call_user_func target, a pipe target that resolves to no name — Checker::record_unresolved_callee_argument_aliases records EVERY argument as aliased, by value included: an unknown callee may bind one by reference, and that reference outlives the call, so ending or re-binding the local would abandon storage the callee still points into. Losing eligibility costs nothing but the feature — the kill degrades to the pre-feature null store, the retype to the pre-feature error.
Shapes 1 and 2 — the ones that END a binding — are additionally gated by Checker::local_binding_is_killable: the local’s CURRENT binding must have been created by a statement that itself sits at conditional depth 0 (not inside an if/loop/try/switch/…) — which can be anywhere in the body, not only its first statement — and the unset/reassignment must itself sit at conditional depth 0 too, so the store that replaces the binding definitely runs.
A for initializer counts as a statement at the depth of the for itself. check_stmt opens one conditional scope for the whole loop, but the initializer runs exactly once, unconditionally, whenever the statement does, so the For arm checks it with the depth lowered back (and mixed_storage_scan mirrors that): for ($i = 0; …) binds $i exactly like $i = 0; written before the loop, and a later straight-line unset($i) or $i = "done"; ends that binding instead of failing. The condition and the update repeat with the body and stay at the body’s depth.
require_once’d top-level code never satisfies that: its include guard lowers to a runtime branch, so every TOP-LEVEL statement of the included file sits at depth ≥ 1. That covers the included file’s TOP-LEVEL statements ONLY: enter_local_binding_scope resets the depth counter per body, so a function/method/closure declared in the same file starts its own locals at depth 0 and both shapes apply to them as usual. Plain require splices the file in unguarded, so even its top-level statements behave like inline code.
Shape 1 has one further top-level exclusion of its own, Checker::program_global_names: inside main, a name declared global by any function-like STATEMENT body of the program lives in a _eir_global_* symbol other bodies reach by name, so unset keeps the binding there and is a plain typing no-op. That set is crate::global_decls::collect_global_var_names, the ONE walk EIR lowering reads as well, and it descends into statement bodies only: a global written inside a closure body, an assignment prelude, or an enum method is invisible to the veto and to lowering alike — a deliberate blind spot shared by both sides, tracked upstream, with both directions of widening it measured and rejected (see the module preamble for the measurements). Shape 2 is deliberately NOT gated by that list — the retype through such a name lowers correctly today, and vetoing it would reject working code.
local_binding_is_killable also answers false for EVERY name in a body that calls eval() anywhere — the eval scope reaches caller locals by name, while ending a binding hands the name a different frame slot — which is why unset is a plain typing no-op in such a body and an incompatible reassignment there is the hard error in both modes. Shape 3 is gated by neither rule: it never ends a binding (see its own paragraph below), and requiring depth 0 would exclude its flagship if/else example, whose two assignments are both inside branches.
-
unset()kill.unset($a)on an eligible binding removes$afrom the type environment entirely. A later READ isUndefined variable: $a; a later assignment binds$afresh, at any type, with no warning. This kill is not gated bystrict_locals—unsetbehaves identically in both modes, because dropping a binding is always sound where the eligibility test holds. -
Straight-line retype. A plain statement-form reassignment (
$a = 0; $a = "ciao";) — including a compound form the parser desugars to a plain assignment, such as$x = 1; $x .= "a";— re-binds the name to a fresh binding of the new type and pushes this warning instead of failing:$a changes type from int to string; the previous value is discarded (compile with --strict-locals to make this an error)Only STATEMENT-form assignments are eligible; an expression-form assignment (
$b = ($a = "s");) keeps the hard error, because its result has no single well-defined type to hand the enclosing expression. -
Branch-divergent assignment (boxed
Mixedstorage).run_mixed_storage_scanruns a purely SYNTACTIC pre-scan of a body BEFORE it is type-checked, looking for a local whose statement-form assignments cannot all merge — the shape ofif (…) { $a = 0; } else { $a = "ciao"; }, a single-branch retype of an outer binding, or a heterogeneous loop-carried local. A marked name bindsPhpType::Mixedon EVERY assignment path, not just at its first store:merge_local_assignment_typeconsults the mark before it looks at the environment at all, so neither the retype hook nor the hard error ever fires for it. Re-asserting the mark at each store is what makes it dominate flow NARROWING — a guarded branch writes the narrowed type straight into the shared environment, and a first-store-only consult letif (…) { $a = 1; } else { $a = "s"; } if (is_int($a)) { $a = "z"; }reach the merge boundintand fail with the hard error in both modes. EIR lowering gives the name boxed frame storage for the whole body — every later read dispatches on the boxed value instead of using a plain register/stack slot. One warning is pushed per marked name:$a is assigned incompatible types (int and string); it is compiled as boxed mixed storage (compile with --strict-locals to make this an error)The pre-scan only trusts evidence it can type EXACTLY from an expression’s shape alone: a literal, a scalar cast (not
(array), whose element type is not knowable syntactically), or a.string concatenation (which always yieldsStrregardless of its operands). Any OTHER write to the name anywhere in the body — a plain variable or call-result value,++/--, aforeach/list()target, anunset()mention,=&,global, orstatic— disqualifies the whole name from marking, and the pair falls back to the hard error. An assignment inside a branch guarded by a NON-NEGATED type test on the name itself (if (is_string($a)) { $a = "x"; }) is skipped rather than counted: the guard established the type the assignment writes, so it is not evidence of divergence.PASSING the name to a call disqualifies it too, on a rule stricter than the one shapes 1 and 2 use, because this scan runs BEFORE the body is type-checked and holds nothing but
checker.fn_declsand the builtin registry — both keyed by NAME.callee_may_bind_arguments_by_refcan therefore only answer for aFunctionCall(and for theFirstClassCallable(Function(name))class ofPipetarget), and answerstruefor a name it fails to resolve. Every other call shape —MethodCall,NullsafeMethodCall,NullsafeDynamicMethodCall,StaticMethodCall,NewObject,NewScopedObject,NewDynamic,NewDynamicObject,ClosureCall,ExprCall— routes straight todisqualify_call_arguments, which disqualifies the root local behind EVERY argument, by-value ones included. So$c->m($a)with an ordinarym(mixed $v)costs a branch-divergent$aits mark and leaves it on the hard error, where the identicalf($a)keeps it. The asymmetry is deliberate: over-disqualifying only withholds a mark, while under-disqualifying would box a local a reference still reaches.A PARAMETER is never marked (it is already bound when the body starts, at a type the frame’s ABI fixed), but a by-value closure CAPTURE — the other pre-bound shape — is: suppressing its mark strands the value the capture owns. Its warning is withheld when replaying the name’s assignments from the capture’s INCOMING type merges cleanly, because the advice to compile with
--strict-localswould be false there; the mark and its boxed store sites stay either way.
Storage-only numeric re-bind. A COMPATIBLE statement-form reassignment can also end a binding, silently and for storage alone. A frame slot is typed by everything ever stored into it, so for ($i = $n - 1; …; $i--) after for ($i = 0; $i < $n; $i++) used to widen the one slot both loops shared to boxed Mixed (a checked int - int is mixed until proven), and the first loop, whose own updates are provably integer, ran boxed too. merge_local_assignment_type therefore records the assignment in local_retype_sites when splits_numeric_binding holds: both the binding’s type and the assigned type are numeric (int, float, the int|float an increment leaves, or mixed), they differ, and the assigned value cannot borrow the old binding’s cell (a raw int/float, or a fresh arithmetic result), because the re-bind releases the old occupant before the store retains the new value. Eligibility is shape 2’s (local_binding_is_killable, statement form, a span that names a node), plus the program-global veto, and parameters are excluded (Checker::local_parameter_names): backtrace frames read a parameter’s current argument value from the first slot of its name. Unlike shape 2 nothing typing-visible changes: the environment still takes the MERGED type and no warning is filed, so --strict-locals does not affect it. EIR lowering re-binds the slot at the same rebind_local_for_retype point; it skips the zero_local_slot a numeric slot does not need (raw scalar storage, or a boxed slot release_local_slot just cleared), and checked_int_sink accepts a retirement no later access can reach, so the old binding can still narrow to a raw integer. get_defined_vars() orders a name by the first slot that ever carried it, so this re-bind, like a retype or an unset kill, leaves the variable at the position PHP keeps for it.
--strict-locals turns shapes 2 and 3 back into the hard error:
Type error: cannot reassign $a from int to string
Shape 1 (unset() kill) is unaffected in either mode.
Every parsed file instance (each include execution and each autoloaded class file) lexes with its own source identity packed into its spans (see the lexer), so two statements at the same line and column of DIFFERENT files are distinct decision keys. A key can still name more than one node of one parsed source, and when such a collision lands on a retype/kill/mixed-storage decision, binding_decision_ambiguity.rs rejects it with Cannot re-bind $a here: … instead of silently mis-lowering one of the two sites. It also checks the mixed-storage keys a later scan REMOVED (retired_mixed_storage_store_sites): a re-decision drops the decisions filed against the sites it is about to re-decide, matching by (span, name), so one body’s scan can strip another body’s decision and leave nothing behind to reject. The kill and retype maps are deliberately not given the same treatment: a stripped decision there falls back to the pre-feature lowering path, which is correct, whereas a stripped mixed-storage decision removes the name from CheckResult::mixed_storage_local_names() while the checker still types the local Mixed.
Nested numeric locals can also split at the starts found by
src/types/checker/numeric_regions.rs. This syntactic scan requires every use of a
name to lie inside a maximal initialized region: a for initializer through its
loop exit, or an assignment through the end of its statement list. A read before
an initializer or after its region vetoes that name. Incoming names, captures,
possible reference arguments and scope-observing constructs are excluded. Every
assignment must remain numeric. Checked arithmetic can depend on other region
locals; a later nonnumeric writer retracts the complete transitive dependency
graph of region decisions. numeric_region_retypes records decisions justified only
by this scan, so retraction preserves independently valid straight-line numeric
splits. The original assignment spans enter local_retype_sites, so
existing ambiguity and AST-duplication guards apply to these decisions too.
eval()’d code — whether interpreted by the optional Magician bridge or AOT-lowered from a literal fragment into an EIR scope function — reads and writes its locals through a boxed Mixed scope cell rather than a compiled, typed frame slot (see eval() scope behavior), so it was never subject to this rule to begin with: --strict-locals has no effect inside eval() fragments. The body that CALLS eval() is the reverse case: because the scope cell is addressed by NAME, shapes 1 and 2 would hand the fragment a slot the rest of the body no longer uses, so mixed_storage_scan records a per-body “contains eval” flag (Checker::body_contains_eval) and local_binding_is_killable refuses both there — body-scoped rather than point-in-time, since eval_barrier_active only rises once the walk has PASSED an eval and the hazard includes an eval below the site. See --strict-locals for the CLI flag.
Per-file strict_types
declare(strict_types=1) is scoped to one physical file, but the checker only ever sees the single flat program the resolver produces after include/autoload merging, and Span carries no file identity. The flag therefore rides on the AST: the parser records the directive on its per-file source profile (crate::source), Stmt::strict_types inherits it at construction alongside Stmt::source_mode, and every statement-rewriting pass re-installs the whole SourceProfile — which is why with_parse_mode/scoped_parse_mode take the profile rather than the mode alone.
Checker::check_stmt installs Stmt::strict_types on Checker::strict_types and restores the outer value afterwards, so the setting always reflects the file the call site was written in — matching PHP, where a strict file calling into a coercive one is strict and a coercive file calling a function declared in a strict one is not. Checker::require_strict_types_param_binding then runs before types_compatible, because the widenings PHP drops in strict mode (bool→int, int→bool, …) are ones types_compatible accepts on its own. Checker::with_internal_callback_binding suspends the flag while validating a callback that an internal function invokes (array_map, usort, …), which PHP calls from an engine frame that never carries the directive.
None of this is affected by the permissive local retyping described in Local retyping and strict locals mode above: a declared type stays strict regardless of strict_types or --strict-locals.
int $x = 42;
$x = "hello"; // ← Type error: cannot reassign $x from int to string (declared type, strict in every mode)
This is intentional — it lets the compiler know exactly what a declared $x is at every point, without needing runtime type tags.
Statement checks
Statement checking validates control-flow constraints that are not expression
types. foreach accepts indexed arrays, associative arrays, values typed
Iterable, and objects/interfaces that implement Iterator or
IteratorAggregate. Indexed and associative array loops bind key/value
variables to inferred element/key types; Iterable and object-backed iterator
loops bind them as Mixed because concrete payload tags are discovered at
runtime. break and continue track the active loop/switch target depth, so
break 2; is accepted only when two enclosing break/continue targets exist in
the current function or closure body. Function, method, and closure bodies reset
that depth so an inner declaration cannot target an outer loop. finally bodies
also record the target depth at entry: break or continue may target
loops/switches created inside that finally, but jumping out of a finally
block is rejected to match PHP.
Loop-carried storage contracts
File: src/types/checker/loop_storage.rs
A loop body is checked once, with the environment as it stands at loop entry. That is fine for a local the body only reads, and wrong for one the body rebinds: the back edge carries the new value back to the top, so the second iteration starts holding something the first iteration’s types never described.
analyze_loop_storage() runs before the body is checked and closes that
gap. It collects every value assignment and every array write in the body (plus
the for update clause), then replays them against a copy of the entry
environment until the types stop changing — a fixed point, so a promotion can
cascade through intermediate locals and through later iterations. The result is
a list of storage contracts: the representation each affected local must have
for the whole loop. stabilize_loop_storage() installs them into the
environment, and EIR lowering consumes the recorded contract instead of
re-inferring, which keeps non-literal right-hand sides aligned across the two
layers.
Only locals that need an up-front representation are reported. Three entry shapes qualify:
- Array locals whose stable form needs boxed payloads (
array<int>that also receives a string becomesarray<mixed>), or a boxed whole value when the container kind itself varies across the body. - Null locals — a local that enters holding
nulland is assigned something else inside the body becomes boxedMixed. - Tagged nullable integers whose loop writes can produce a float or boxed value need a boxed contract. Externally shared tagged references retain their fixed layout.
The null contract is boxed Mixed rather than the fixed point’s own join. The
join of null and int is already Mixed, but the join of null and array
is array — right for an array-typed entry that is merely growing, wrong here,
because the first read still happens before the assignment and still sees
null. Mixed is the one representation that holds both on every path, and it is
what keeps a === null guard a real runtime tag test instead of a constant the
optimizer folds:
$n = null;
for ($i = 0; $i < 3; $i++) {
if ($n === null) { $n = 0; } // without the contract: folded always-true
$n++;
echo $n; // ...so this printed 111, not 123
}
The widening is driven by evidence, not by the entry type alone: a loop that
never assigns the local leaves it null, so a null local a loop only reads is
untouched (issue #562).
Expression type inference
The type checker computes the type of every expression:
Literals
| Expression | Type |
|---|---|
42 | Int |
3.14 | Float |
"hello" | Str |
true / false | Bool |
null | Void |
[1, 2, 3] | Array(Int) |
[1, "two", true] | Array(Mixed) |
["a" => 1] | AssocArray { key: Str, value: Int } |
["a" => 1, "b" => "two"] | AssocArray { key: Str, value: Mixed } |
A literal that mixes spreads and explicit keys uses AssocArray with Mixed
keys. Each spread contributes its source’s element type once, so spreading an
array of arrays preserves the nested array type in the resulting value slots.
An indexed literal with spreads ([...$list, 1]) stays an Array while every
spread source is a list, and becomes an AssocArray with Mixed keys as soon
as one source is a hash or mixed. The scan that decides this types each spread
source once, and the element merge that follows reuses those types: typing a
source again would double the work at every level when such literals nest under
?? or a ternary.
Binary operations
| Operation | Types | Result |
|---|---|---|
Int + Int | arithmetic | Int |
Float + Float | arithmetic | Float |
Int + Float | mixed arithmetic | Float |
Int / Int | division | Float (always — matches PHP) |
Int % Int | modulo | Int |
Str . Str | concatenation | Str |
Int . Str | concat with coercion | Str |
Int > Int | comparison | Bool |
Bool && Bool, Bool and Bool, Bool xor Bool | logical | Bool |
Int & Int | bitwise | Int |
Int <=> Int | spaceship | Int (-1, 0, or 1) |
expr instanceof ClassName | class/interface metadata check | Bool |
expr ?? expr | null coalescing | Left type without null, joined with the default’s type; the left type alone when the left operand is never null (see Null-coalesce results) |
print expr | output expression | Int (1) |
Function calls
Built-in functions have hardcoded type signatures (see below). User-defined functions, methods, constructors, closures, and arrow functions can carry declared parameter hints; functions, methods, closures, and arrow functions can carry declared return type hints. Declared non-void returns are validated both against returned values and against reachable fallthrough paths, while throw, exit()/die(), and provably infinite loops count as non-returning paths. Closure / arrow return annotations are represented in the AST and threaded into callable FunctionSig metadata; unannotated closures continue to infer their return type from the body or expression. Named arguments are validated against the declared parameter list before the usual argument-count and type checks run, including built-ins, extern calls, associative-array spreads with string keys, and spread prefixes that fill earlier positional slots. Variable AssocArray spreads before named arguments are treated as dynamic named providers, so required-parameter diagnostics are kept only when no preceding associative spread could provide the missing parameter at runtime. Unknown named arguments on user-defined variadic functions are accepted and typed as part of the variadic parameter, while built-in variadics reject unknown named arguments like PHP internal functions do. Positional spreads into variadic callees fill regular parameters first, then type the remaining tail as the variadic array.
Codegen receives enough signature information to evaluate named/spread arguments in PHP source order while still materializing values in ABI parameter order.
Eval barrier
eval() is recognized as a PHP language construct rather than a registry-backed
builtin. The checker requires exactly one argument, infers that argument for its
normal side effects, and assigns the call the static result type Mixed.
After an eval call, the caller-visible environment becomes dynamic: the
fragment may overwrite or unset existing locals, introduce new locals, declare
symbols, or mutate referenced storage. The checker therefore marks an eval
barrier, widens known local types to Mixed, discards callable/capture facts,
and permits later reads of variables and class-like symbols that may have been
created at runtime.
This rule is intentionally conservative for literal strings too. The later EIR planner may prove that a literal fragment can be AOT-lowered without runtime scope or interpreter state, but that backend decision must not hide frontend diagnostics or make pre-call type facts unsound. See Eval Runtime Architecture.
Built-in function signatures
Files: src/types/checker/builtins/, plus src/types/checker/mod.rs and src/types/checker/inference/ for special expression forms such as ExprKind::PtrCast, ExprKind::InstanceOf, and ExprKind::Print
Every built-in function has a registered type signature:
strlen($str: Str) → Int
substr($str: Str, $start: Int, $len?: Int) → Str
strpos($hay: Str, $needle: Str) → Int|False
array_search($needle, $arr: Array|AssocArray) → Int|Str|False
file_get_contents($filename: Str) → Str|False
fopen($filename: Str, $mode: Str) → resource<stream>|Bool
fileatime($filename: Str) / filectime($filename: Str) → Int|False
fileperms($filename: Str) / fileowner($filename: Str) / filegroup($filename: Str) / fileinode($filename: Str) → Int|False
filetype($filename: Str) → Str|False
stat($filename: Str) / lstat($filename: Str) / fstat($handle: resource<stream>) → AssocArray|Bool
define($name: Str, $value: scalar) → Bool
count($arr: Array|AssocArray) → Int
abs($val: Int|Float) → Int|Float
floor($val: Int|Float) → Float
rand($min?: Int, $max?: Int) → Int
ptr($var: lvalue) → Pointer(None)
ptr_get($ptr: Pointer) → Int
ptr_set($ptr: Pointer, $value: Int|Bool|Void|Pointer) → Void
ptr_cast<T>($ptr: Pointer) → Pointer(Some(T))
Most entries in the table above come from the builtin signature registry, while pointer-tag casts like ptr_cast<T>() are checked directly when the type checker visits ExprKind::PtrCast. instanceof is also checked as a dedicated expression: it always returns Bool, validates named self / parent / static targets against the current class context, checks dynamic target expressions for ordinary expression validity, and deliberately allows unknown named targets so runtime behavior can return false like PHP. For some built-ins the checker also enforces container shape, not just raw argument count:
array_push($arr, ...$values)accepts indexed, associative, and boxed PHP array receivers. A statically typed associative receiver keeps its entry type instead of widening it (see #1508).array_column($rows, $column_key)requires the first argument to be an indexed array whose element type isAssocArraywordwrap()accepts 1 to 4 arguments, matching the builtin checker
array_push() distinguishes explicit =& groups from the broader set of escaped
reference aliases. Plain values cannot widen a native indexed receiver in an
explicit alias group; fresh boxed closure captures can widen. Runtime spreads
also reject native indexed storage anywhere along an aliased receiver chain.
For properties, the checker records the selected class, any indirect-write
error, and whether the slot supports a live managed reference. Declared array
and mixed slots support that path; inferred native array/hash slots use
copy/write-back for plain values and reject runtime spreads.
The type checker validates:
- Argument count — too few or too many arguments → error
- Argument types — wrong types → error (in some cases; many builtins accept multiple types)
- Return type — used to infer the type of the call expression
Contextual callback parameter typing
File: src/types/checker/builtins/callables.rs
An array builtin types the unannotated parameters of its callback from the array it is given, so the idiomatic untyped closure/arrow function checks correctly:
$words = ["banana", "apple"];
usort($words, fn($a, $b) => strlen($a) <=> strlen($b)); // $a, $b are string
array_map (argument 0), array_all / array_any / array_filter / array_find /
array_reduce / array_walk / array_walk_recursive / uasort / uksort / usort
(argument 1) and array_udiff / array_uintersect (argument 2) are typed this way. Value
parameters take the array’s element type; key parameters take the array’s key type — Int for an
indexed array, the declared key type for an associative one. That is what uksort compares, what
array_filter passes under ARRAY_FILTER_USE_KEY / ARRAY_FILTER_USE_BOTH, and what
array_walk passes as its optional second callback parameter (added only when the callback
literally declares it, so a one-parameter callback still satisfies arity checking).
contextual_callback_arg_positions() is the single source of truth for those positions, and every
eager pre-inference pass consults it. Skipping them matters: inferring the closure before the hook
supplies its hints would check the body once against the unhinted parameter fallback and reject
valid code. Explicitly declared parameter types always stay authoritative, and an element type the
array does not pin down (Mixed/Never) leaves the parameter Mixed.
Null probes (isset / empty / unset / ??)
File: src/types/checker/null_probe.rs
isset(), empty(), unset() and the left operand of ?? / ??= exist to name storage that may
never have been declared, and PHP answers all of them without an Undefined variable warning. The
checker matches that: the spine of the operand’s access chain ($x, $x[...], $x->p, $x?->p)
may bottom out in an undeclared variable, which reads as null. Reaching through a null base is
allowed inside a probe too, so isset($never['k']) answers false instead of “Cannot index
non-array”. Index and property-name subexpressions are not covered — PHP still warns about $b
in isset($a[$b]), so that keeps the ordinary diagnostic, as does every read outside a probe.
Acceptance is decided at the end of the top-level pass rather than at the probe. EIR lowering
derives main’s local types from CheckResult::global_env, so a probed name is only representable
while it stays null for the whole scope: it must finish the pass unbound, and the checker then
seeds it as null so codegen answers from the slot type instead of reading storage no store ever
initializes. A name that is also assigned at top level (if (!isset($cfg)) { $cfg = 3; }) would
get that assigned type on a slot the probe reads before the store, so the original diagnostic is
restored for it.
Null-coalesce results
Files: src/types/checker/inference/expr/null_coalesce.rs, src/types/checker/inference/expr/mod.rs
value ?? default removes null from the left type and joins what is left with the default’s
type. Arrays widen elementwise, two object arms meet at the supertype one side already accepts (or
stay a union of both), and an arm that already mixes an object with a scalar keeps a union:
?(Contract|int) ?? new Implementation() is Contract|int, which an is_int() guard narrows back
to Contract. Every other heterogeneous pair joins to mixed, as a ternary’s branches do, so a
pure object arm against a pure scalar arm (?Contract ?? 'none') is mixed.
The default is left out entirely when the left operand can never be null, because it is dead code
there: a fresh value (a literal, new, clone, a closure, $this) or a call whose target
declares a return type that excludes null (a function, a method called on $this or on a
variable, or a static method). find() ?? new Other() over function find(): Implementation|false
is therefore still Implementation|false. Storage operands never qualify, whatever their type:
?? reads an unassigned or unset() variable, a missing array key and an uninitialized property
as null, and the checker has no definite-assignment analysis to rule those out. Inferred returns do
not qualify either, since a body that falls off its end returns an implicit null its inferred
type omits, and neither do builtin and extern results.
Buffer merges
Files: src/types/checker/inference/expr/buffer_merges.rs
A ternary, ?:, match or ?? with an arm that holds a buffer<T> is typed by its own rule
before the joins above: every arm that completes must have that same buffer type, and the merge
is then that buffer. A throw arm is skipped by its shape (it is typed never by infer_type
and null by the assignment-effects walk), and so are a dead or null default of ?? and a
null left operand. Any other arm (another buffer type, null, mixed, a scalar) is a compile
error: a buffer has no null and no mixed form, and the boxed handle a mixed merge would hold
reads its elements back as null. The EIR merge typing in src/ir_lower/expr/merge_temps.rs
(shared_buffer_merge_type) keeps the same handle word for the same arms.
User-defined function checking
Files: src/types/checker/functions.rs, src/types/checker/functions/
When the type checker encounters a function declaration, it:
- Collects all function declarations in a first pass (so functions can be called before they’re defined)
- Creates a local type environment for the function body (separate from global scope)
- Resolves declared parameter types when type hints are present, and otherwise falls back to the existing defaults / inference path
- Resolves the declared return type when present, and otherwise infers it from
returnexpressions - Validates defaults, call sites, and return statements against the declared types, including PHP-style default parameters such as
int $x = 10and named-argument reordering against the declared parameter names - Stores the
FunctionSig— parameter count, parameter types, return type, reference parameters, and variadic parameter
The FunctionSig struct (defined in src/types/signatures.rs):
pub struct FunctionSig {
pub params: Vec<(String, PhpType)>,
pub param_type_exprs: Vec<Option<TypeExpr>>, // source syntax of each parameter hint, when written
pub param_attributes: Vec<Vec<AttributeGroup>>, // PHP 8 attributes attached to each parameter
pub defaults: Vec<Option<Expr>>,
pub return_type: PhpType,
pub declared_return: bool, // whether return_type came from an explicit return hint
pub by_ref_return: bool, // function &f() — returns a reference to the returned lvalue
pub ref_params: Vec<bool>, // which parameters are pass-by-reference (&$param)
pub declared_params: Vec<bool>, // whether each parameter came from an explicit type hint
pub variadic: Option<String>, // variadic parameter name (...$args), if any
pub deprecation: Option<String>, // #[Deprecated] reason; "" when no reason was supplied
}
param_type_exprspreserves the exact sourceTypeExprof each written parameter hint, alongside the resolvedPhpTypeinparams.param_attributescarries PHP 8 attribute groups attached to each parameter, for Reflection metadata.by_ref_returnrecordsfunction &f()/fn &()declarations: the function returns a reference (alias) to the returned lvalue rather than a copy. It is also the only source forReflectionFunction::returnsReference()/ReflectionMethod::returnsReference(). Every reflection record codegen builds for a callable (a class, interface or trait method, where the trait’s own EIR signature keeps the flag too; the listed entrygetMethods()returns; and the declaring-function record a parameter’sgetDeclaringFunction()returns) copies it into itsreturns_referencefield, and none of them bakes a value. A property hook’sReflectionMethodrecord reads the hook’s&getdeclaration (PropertyHooks::get_by_ref, or the abstract contract’s), and eval reflection reads the same flag through the generated method rows and native-function registration; an abstract or interface&getcontract, which has no accessor, keeps it on the Magician property metadata (EvalClassProperty::get_contract_returns_by_ref,EvalInterfaceProperty::get_returns_by_ref, and theNATIVE_PROPERTY_GET_BY_REFregistration flag for a compiled contract). A record built without a signature (constants, enum cases, properties) carriesfalse.ref_paramstracks which parameters use&(pass by reference). The codegen passes the stack address of the argument instead of its value.
Storage rules for a declared by-reference argument
Because codegen passes the stack address, a declared by-reference parameter is checked against the caller’s storage, not just against the incoming value. Checker::require_by_ref_argument_storage (src/types/checker/type_compat/declarations.rs) is the one place that decides it, and it is the reason a by-reference position is stricter than the by-value position next to it:
| declared parameter | caller’s variable | verdict |
|---|---|---|
needs boxed storage (mixed, ?int, a union) | a concrete non-boxed slot | rejected — the callee can write values the slot cannot represent |
does not accept null (int, float, bool, string) | holds null (PhpType::Void) | rejected — nothing coerces the slot on the way in |
| anything else | matching representation | accepted |
Boxed object references are a narrow exception. When a by-reference call can change an object variable to another type, elephc promotes the variable and its aliases to one shared Mixed cell. A later ClassName &$value call reuses that cell and emits a runtime entry guard, which throws a catchable TypeError unless the current payload is an instance of the declared class. Interface dispatch can likewise widen ClassName &$value to an implementing mixed &$value parameter without copying the cell, so writes remain visible through every alias.
The second row is the one that is easy to get backwards, and it was: types_compatible deliberately accepts Void for Int, Float and Bool, which is correct by value (PHP coerces null to 0/0.0/false for an internal function’s by-value parameter) and wrong by reference. That acceptance short-circuited every later by-reference check, so the argument was admitted with the caller’s local still typed Void while the callee wrote an int through the reference; the first thing the caller then did with the local reached EIR lowering as an operation on a null and produced a positionless unsupported EIR backend feature: icmp for PHP type Void (issue #892, reported through curl_multi_exec()’s $still_running).
php-src draws the same line for a userland function — function out(int &$s) {} $x = null; out($x); is a TypeError there too. It is laxer only for its own internal functions, which is why a curl_* wrapper written in elephc-PHP rejects a null seed that php.net’s examples use; docs/php/curl.md says so where the multi loop is documented.
Checker::declared_type_accepts_null answers “does this declared type admit null” for both this rule and the return position, as one predicate, so the two cannot drift.
declared_paramslets later phases distinguish explicit PHP type hints from inferred/defaulted parameter types.declared_returnlets later phases distinguish explicit PHP return hints from inferred return types.variadicholds the name of the variadic parameter (e.g.,$argsinfunction foo(...$args)). Extra arguments beyond the regular parameters are collected into an array.deprecationcarries PHP 8.4#[Deprecated]metadata when present, so call sites can surface the warning consistently.
Call-site inference for untyped parameters
Parameters without a type hint start from an Int fallback and are specialized from the actual argument types observed at call sites. The first observed call discards the fallback exactly once and adopts that argument’s type, so an all-string (etc.) parameter is not polluted by unioning the fallback; the discard is remembered, so a genuinely later int call widens instead of re-adopting. When later call sites disagree, the parameter widens conservatively: a null argument combined with int under the default tagged null representation becomes the inline int|null union (a genuinely nullable scalar), two different object types keep the first object type so object-typed dispatch keeps working, and any other mix widens to Mixed, so those arguments are boxed at the call site and unboxed where they are used. Callable arguments never retype the parameter itself — the callable’s signature is recorded against the parameter name in callable_param_sigs instead. Under the legacy sentinel null representation (--null-repr=sentinel), a null argument never specializes a parameter.
The same accumulation applies to instance-method and static-method parameters. Closure parameters specialize to the first observed argument type but do not widen to a union, so a closure invoked with incompatible argument types is rejected rather than coerced.
Source instance interface methods have no body or concrete call-site type to infer from. Their untyped parameters and untyped returns use Mixed at the interface ABI. Untyped parameters in the corresponding implementing methods also keep Mixed storage instead of being specialized by direct calls. A declared implementation parameter cannot narrow an untyped interface parameter. This gives interface dispatch one stable argument layout while concrete return values are boxed by the interface wrapper when needed.
After a method body stabilizes, its inferred return type and callable-return metadata are propagated to every inheriting class view that dispatches to that implementation. Calls through a child type therefore use the same return ABI as the inherited method body.
Because that specialization is final, a null argument is excluded from it (specialize_callable_var_sig_from_args). Void is the one type no later call could satisfy, so adopting it would close the parameter to null alone — $f(null); $f(5); was rejected with “parameter $v expects Void, got Int” where PHP prints nx (issue #567). Skipping it also makes the two spellings of the same call agree: for function ($v = null), $f() and $f(null) pass the same value, and only the second one used to close the parameter.
This information is then used when checking calls to that function.
When no direct call site exists
The Int fallback is only sound because a direct call site replaces it. A function reached
only through a dynamic callable — a runtime string name passed to call_user_func,
array_map, an ob_start handler — has no such site, so every untyped parameter keeps the
fallback, and a return inferred from one records Int for a value that is really whatever the
caller passed.
So a function no call site ever passed arguments to, whose un-hinted body hands one of its
untyped by-value parameters straight back, records mixed instead
(src/types/dynamic_params.rs, applied by widen_dynamic_only_passthrough_returns once every
signature exists and every direct call has been seen):
function h($b, $p) { return $b; }
$fn = 'h';
var_dump(call_user_func($fn, "probe", 9)); // string(5) "probe"; was int(0)
It is recorded in the checker, not during lowering, because the declaration and its call
sites have to reach the same answer: EIR already boxes such a parameter, and widening only the
callee left the caller reading the boxed cell back as a raw integer. ir_lower applies the same
predicate to methods when it normalizes their ABIs, through the same module, so the two cannot
drift apart.
The rule is narrow on purpose, in two ways.
Only a return that yields the parameter itself counts — through the pass-through shapes
(?:, ??, match, @, assignment) — so a body that computes its own result keeps the type
it inferred (function add($a, $b) { return $a + $b; } still returns int), and a declared
return type is authoritative and never overridden.
And it applies only where no call site ever passed arguments. That is tracked explicitly in
functions_called_directly rather than inferred from whether a signature exists, because the
two differ for the shape that matters: array_map(h(...), […]) resolves h while checking
the callable expression — inserting a placeholder-based signature — without ever calling it, so
h’s parameters still hold the placeholder and its return still needs widening.
Applying the widening to every pass-through body instead feeds back on itself:
function grow($arr) { … ; return $arr; }
$arr = grow($arr); // recording mixed makes the LOCAL mixed…
// …which re-specializes the parameter to mixed…
// …and array_push($arr, …) inside the body stops checking
A function with a direct call site already learns its real parameter types from it and needs nothing here (issue #576).
Hint-less return inference
A function with no return hint gets its signature from folding the types of its return
statements pairwise through Checker::wider_type
(src/types/checker/functions/returns.rs). The rule that matters there is what may absorb
what, and it is ordered deliberately:
| pair | result | why |
|---|---|---|
T, never | T | a diverging path contributes nothing |
T, null | T|null | null is a value, not a width |
T, false / bool | T|false / T|bool | same — a sentinel is not a width |
anything with mixed | mixed | mixed admits everything |
| a union with anything | the normalized union | a union already carries its members |
string, other scalar | string | PHP’s coercion order |
float, other scalar | float | PHP’s coercion order |
| otherwise | mixed | no honest narrower answer |
Sentinels are never absorbed by a width. string swallowing false inferred plain
string, so a function returning false on failure lowered that arm as a string and the
caller saw "" — $r === false was simply wrong. float did the same with 0.0 and bool
was stringified to "1". int|false escaped only because it fell through to mixed, which
carries the value distinctly; that accident is why the defect first looked specific to the
image builtins that reported it (issue #398).
A union is never absorbed either, which is what makes the fold survive a third return.
The fold is pairwise, so false, "." . $ext, $ext builds string|false from the first
pair and then has to keep it against the third — before this, string ate it and the union
held only for exactly two returns. The same flaw was live on the null arm.
Unions are built through normalize_union_members
(src/types/checker/type_compat/unions.rs), the same normalizer declared types use: it
flattens nesting, lets mixed absorb, dedupes, and applies PHP’s bool/false subtype rule
so the two never coexist as separate members. Sharing it matters beyond tidiness —
PhpType and FunctionSig compare structurally, and callable-return metadata is keyed on
that equality, so a hand-rolled string|false|bool would stop matching the string|bool a
declared spelling produces.
The result is that a hint-less union return is byte-identical to writing the hint out, and
codegen_repr() maps it to the boxed representation that carries the sentinel rather than
coercing it into the other arm’s zero value.
Return-to-argument alias summaries
After checking, a lightweight AST pass (src/types/return_alias.rs) records, for every
source-declared function and method, whether its result can be storage the CALLER still owns.
The answer is one of three things: None (every return path is independent of the arguments),
Parameters({i, ...}) (a return path can hand parameter i’s storage back), or Unknown.
EIR lowering reads it at the call site. A result proven to alias an argument is BORROWED, so
the caller must not release it; anything else is owned, and the caller releases it once it is
consumed. Getting that backwards costs one leaked block per call in one direction and a
use-after-free in the other, so the pass is deliberately conservative: it merges provenance
across branches, runs loop bodies to a fixed point, and answers Unknown for any storage path
it cannot follow.
The rule that is easy to get wrong is the cast. A cast is only alias-transparent when EIR
lowering ELIDES it, and lower_cast elides exactly one shape: (string) over a value whose IR
type is already Str, which compiles to nothing at all. Every other cast emits Op::Cast,
whose backend helpers write into storage independent of the source — (string) over a boxed
mixed COPIES the payload into a fresh allocation, and (array) allocates even when its
operand is already an array. Treating every cast as transparent is what made
function f($v) { return (string)$v; } leak a full copy of the string on every call
(issue #700).
So the pass has to answer a question the declarations alone cannot: is this operand’s slot a
bare Str RIGHT HERE? It tracks that in str_slot_locals, a flow-sensitive set of names
alongside the provenance map:
- It is seeded with the visible parameters declared exactly
string. Only those get a bareStrslot in the first place. - It is never gained. A local is boxed Mixed whatever is written into it —
$x = $sover astringparameter still lowers throughmixed_box— so no assignment can add a name. - It shrinks when a
stringparameter is assigned something that is not itself bare-Str(amixedparameter, or a boxed local such as$c ? $p : $q), and when the name is bound by reference, since&writes are not visible as ordinary assignments.++and--also remove the name because EIR stores their result in a boxedMixedslot. - It is intersected at every control-flow merge: a slot counts as bare
Strafter anifonly if it was one on every path into that point.
A (string) cast is a candidate passthrough only when its operand is still in that set. The
lookup sees through exactly the wrappers expr_alias treats as transparent — @$s, a named
argument, a spread, and an already-elided inner (string) — so return (string)@$s and
(string)(string)$s behave like return (string)$s. If the two disagreed about which
expression “the operand” is, the result would be a use-after-free rather than a leak.
Assignment expressions require their result storage to be checked as well. For a variable
target, EIR reloads the target after storing the source; (string)($a = $b) borrows $b only
when $a still has a bare Str slot and $b also supplies bare Str storage. A boxed target
such as a mixed parameter, or a string parameter boxed by an earlier increment, causes the
outer cast to copy. For a simple array element or other non-local target, EIR reads the
explicit result target after the write; when that is the original bare Str source, it
remains borrowed. The provenance analysis applies any target-stabilization prelude before
classifying that result target.
Passing that gate is necessary but not sufficient, because the slot can be bare Str while
holding a DIFFERENT parameter’s storage:
function f(string $a, string $b): string { $a = $b; return (string)$a; } // borrowed from $b
function g(string $a, mixed $b): string { $a = $b; return (string)$a; } // a fresh copy
In f the storage now in $a came from another bare Str slot, so $a stays in the set, the
cast is still elided, and the summary names $b — the provenance, not the index the operand
was spelled with. In g the assignment widened $a, it leaves the set, lower_cast emits
Op::Cast, and the caller owns the copy. The second gate therefore re-checks the provenance
itself: only one whose every parameter was declared string keeps the passthrough. Anything
else answers None, and an Unknown provenance stays Unknown, because that is the one case
where claiming independence would be a use-after-free rather than a leak.
Type narrowing (is_* / instanceof / strict-comparison guards)
File: src/types/checker/stmt_check/narrowing.rs
Inside an if (or ternary) guarded by a type predicate, the checker narrows the guarded binding’s type for each branch. is_int/is_integer/is_long, is_float/is_double, is_string, and is_bool narrow to the corresponding scalar; $x instanceof Class narrows to that class; is_null($x) and the strict comparisons $x === null and $x === false (in either operand order) narrow to null and to the literal False subtype respectively. $x !== null / $x !== false and single-operand isset($x) are the same guards with the branches swapped, so a leading ! on them cancels out. The then-branch sees the guarded type and the else-branch sees the complement (a Union drops the matched members); a leading ! swaps the two. The false-sentinel case preserves the literal false: after if ($x === false) { throw ...; }, an int|false value continues as plain int, while a full bool member is not stripped.
is_real is an extension float guard only where extensions are enabled. A physical PHP
statement under --strict-php does not narrow through a user function named is_real;
LFC statements retain the builtin guard even under that flag. Both statement checking
and the mixed-storage pre-scan install each statement’s source_mode, so a function’s
guards and boxing warnings do not depend on whether its caller was written in PHP or LFC.
The guarded receiver may be a variable, a simple instance property ($var->prop, $this->prop), or a simple static property (self::$p, Cls::$p). static::$p is never narrowed, because late static binding can select a subclass that redeclares the property. Property narrowings are stored under a synthetic environment key and are conservatively dropped after anything that could mutate the property — a property assignment, any call, or loop-body entry — and dropped per-object when the root local is rebound. A call keeps the narrowing of a readonly property of $this (declared readonly or in a readonly class): an initialized readonly property cannot be written again, and $this cannot be rebound. Any other root may be rebound by the call (a by-reference parameter, a =& alias, a global, a by-reference closure capture), and PHP 8.3+ can reinitialize a readonly property while cloning, so a call drops the fact for every other root and in any hierarchy that declares __clone. Properties backed by PHP 8.4 get hooks or __get are never narrowed, because two reads may produce different values. Guard detection never raises a diagnostic of its own: a receiver whose type cannot be inferred is simply not narrowed.
Compound guards are composed in src/types/checker/stmt_check/narrowing/conditions.rs. Each guarded binding has true and false types. The right operand is analyzed under the left operand’s true facts for &&, or false facts for ||. The two paths that can produce false for && (or true for ||) are joined, so facts that hold on only one path do not escape. Negation swaps the outcomes recursively. Unknown calls and writes conservatively discard earlier facts before composing the right operand. Statement branches, ternaries, while bodies and short-circuit operands consume the same guard sets.
Local coalescing assignments retain the existing assignment compatibility checks, then publish the non-null part of the binding when the default excludes null. A default that may execute joins its effects on other locals, just like an ordinary coalescing expression; a stable already non-null target keeps the skipped-default state. This changes the local’s flow view while its nullable parameter storage remains available to EIR lowering.
Boolean inference carries guard summaries forward through associative chains instead of recomputing each growing prefix. Ternary branches keep their original storage contracts while checking narrowed reads; divergent guard complements retain that contract after the region closes. Compatible stores preserve the read view and storage representation; widening or rebinding (unset, destructuring, foreach, catch variables, =&, global, or static) ends the retained contract. Retained storage is consulted only while it still contains the binding’s current type. Reference captures restore the cell’s storage type before checking the closure and invalidate the enclosing read view. Reference validation uses the storage type in both its checks and diagnostics. A non-null object or mixed reference call keeps the established Mixed output view for callers whose guarded storage is already boxed; it does not restore a narrower nullable read contract after the call. Statement and expression forms share union EIR storage preservation for coalescing and compatible assignments. Lazy branch-result typing retains tagged locals’ semantic int|null alternatives when choosing merge storage.
Non-assignment effects, including increments, reference-output calls and builtin output parameters, retire active guard origins and discard retained storage when the new type requires a wider representation. A guarded mixed reference argument to a registry builtin publishes the same promotion metadata as a user call. Nullable integer updates use checked arithmetic once a guard or coalescing has proved the read is an integer; static settype() honors the approved reference-cell promotion before writing.
Conditional expression arms snapshot their guarded storage and narrowing origins through src/types/checker/inference/expr/conditional_flow.rs. Nullsafe member names and arguments snapshot every visible local, retaining both skipped and evaluated writes. EIR nullsafe chains observe local representations after evaluating the base and at segment boundaries, then publish a shared read representation for every possible continuation. Integer/null alternatives retain their semantic union for the existing tagged local-load ABI; plain pointers retain address storage. Other differing alternatives use boxed storage. Each alternative is checked with its entry contracts; its exit types are joined for existing guarded locals after all alternatives are checked. Match and short-ternary result types are inferred before publishing these joins. Tagged integer/null storage is preserved only for compatible integer/null representations, so dynamic mixed defaults and boolean stores can widen the slot.
if and while also join writes to previously guarded locals before restoring their temporary read views. A store that may contain null consults the retained binding even inside a nested guard. Reference validation prefers this compatible physical contract over the nested guard’s read origin, which may already be non-null.
The snapshots include boxed and tagged locals that a new guard can narrow. This keeps checked integer increments and other widening writes visible after the first guard on a variable, before a later reference call chooses its storage ABI. Merely opening a guard does not count as a write, and an untouched terminal guard retains its complement.
binding_flow.rs tracks definitely_bound_locals independently of assignment compatibility, binding depth and physical storage. Parameters and captures seed each function-like scope. Successful stores establish presence; unset and opaque eval effects invalidate it. Flow snapshots intersect presence across reaching branches, loop exits, switch entries and exception continuations, including skipped paths. Pure inference and diagnostic-only alternatives restore the entry set. A missing nullable binding retains null in its read view even when another path completed a fallback. A successful non-null store to a previously missing stable local retracts that null alternative while retaining its physical contract, including when another branch used the same counter or stream name. A stable non-null guard can establish presence on its own path. Raw scalar locals retain their existing compatibility behavior.
CheckResult::local_value_read_types joins authoritative value contracts by body scope, source span and local name. An integer projection requires a stable binding, definite presence and a checker-recorded tagged storage contract refined by a guard or fallback. An inferred integer array-element type alone does not exclude a missing-key null. Storage-analysis probes do not publish executable facts and restore deferred top-level null-probe roots after speculative inference. Checker-owned callback argument placeholders are present by construction. EIR loads the physical tagged slot or reference cell before applying a proven integer projection. Nullable captures retain the pair. An unstable integer guard instead captures the actual value as boxed Mixed, so the closure preserves null, accepts existing dynamic scalar bindings and verifies declared integer returns through the normal runtime boundary. Nonparameter tagged frame locals initialize to canonical PHP null before any branch can skip their first store. Runtime closure descriptors store and reload both words, plan hidden arguments with the physical representation and mark inline scalar captures separately from boxed heap owners for release and GC traversal.
An empty array on a skipped loop-entry path is neutral when joined with a populated hash. Its return boundary can materialize the empty hash representation, so generic array return inference preserves the hash shape instead of falling back to indexed storage.
Strict null comparisons select a known arm at statement entry only when they directly read a stable, definitely-bound local whose nullness is established. A non-null value needs a retained checker refinement. Assignment comparisons are narrowed after their operand effects, so entry-arm selection cannot use the target’s previous value. An inferred array-element type alone cannot remove a reachable null path, including a later loop break that carries a different return type. Ordinary and coalescing stores into nullable bindings retain null when their RHS lacks a non-null value guarantee. Fresh values, declared non-null returns, retained stable local refinements and audited builtin contracts can establish that guarantee. The store proof also follows returning ternary/match arms, coalescing defaults, simple assignments and suppression wrappers, and recognizes non-null scalar operators, constants and declared property contracts. An array-element or untyped-property read alone cannot establish it. Loop source filters apply inside composite results as well.
Same-type raw contracts record value proofs separately from boxed and tagged physical provenance. A raw proof alone does not promote a mutating builtin’s array argument to a Mixed cell. For retained nullable storage, the shared semantic descriptor identifies reference operations that leave an array on successful return; their outputs preserve the non-null array family while arbitrary Mixed-reference writes still erase that read view. Function, method and closure checking seeds those proofs from declared unboxed parameters in the actual body environment. Unproved replacement ends the proof. Conditional publication drops raw proofs introduced after entry or affected by a branch write, including names absent from the tracked read set. Continuing loop writes, reference outputs and binding removal retract raw entry proofs. Reachable catch-variable bindings count as object writes when their handler can reach another iteration; a handler ending in a terminal break or return does not change a later loop header. Handler reachability shares the exception analysis and uses conservative loop-local retirement bounds, so an unused throwing destructor does not make scalar assignments throw. A finite type-node budget widens recursive bounds to Mixed before later iterations can duplicate them. Catch binding and global rebinding record destruction of the previous value; a catch-binding exception escapes to outer handlers, and the same edge keeps those handlers alive during AST pruning. Reachable catch bindings join those bounds to a fixed point; reference exposure, by-reference foreach roots, global/static cells and opaque writes prevent scalar retirement proofs. Inline callees discard the caller’s local bounds. Backedge stores into retained nullable storage use the same non-null value guarantee as ordinary stores before joining loop-header reads. A copied source keeps its entry proof only if no assignment, reference output, binding removal or opaque caller-local effect in the loop can write it; physical tagged storage remains intact. A finally join retains a raw proof only when the after-state still carries it; joining physical storage cannot recreate one that a write killed.
Sibling clauses restore raw proofs from the evaluated condition prefix before checking the next condition or body. This preserves condition writes that kill or establish a proof, while branch-body proofs stay local to that branch. Nullable frame storage introduced by a sibling remains available without replacing a proof established by the condition. Optional include-once bodies retain only raw proofs also present at entry. Pure assignment and ternary inference, and diagnostics for a proven-skipped coalescing default, preserve definite presence, guarded storage and narrowing origins. An inferred non-null type without a retained value proof uses conditional-flow effects for its possibly evaluated default. Default null widening applies only to nullable destination contracts; compatible raw destinations keep their existing checks.
Switch arms start from the complete flow snapshot after their own case expression has been evaluated. Multiple labels for one arm join their evaluated prefixes, and body fallthrough joins that direct entry separately. Later case expressions cannot supply value proofs to an earlier matching arm. The unmatched/default path, physical storage analysis and strict assignment replay still use the state after all labels.
The back-edge scan also records whole-local unset operations and opaque eval/extract effects independently of narrowed storage. Before checking a loop body, those possible continuing paths retract definite presence and join nullable entry reads with null. Terminal paths do not contribute these kills. A new fallback at the top of the body can establish a fresh integer proof for that iteration. Speculative loop inference restores the entry presence set before publishing the resulting facts.
Loop storage analysis also carries the physical tagged-integer provenance of coalesced reads. A checked update adds the float alternative before the loop body is checked, so an earlier reference call sees the boxed loop representation. This contract is limited to narrowed tagged reads or integer-update evidence, uses precise checker expression types, and is emitted only for a numeric union. Ordinary nullable bindings keep their nullability when unrelated writes require different storage. Numeric joins retain null where it remains possible; compatible integer-only stores keep their tagged representation. Overwrites of scalar slots retain deferred slot retirement even before a later widening store or branch join is lowered: final boxed storage also boxes the entry value and earlier stores, whose owners must be released. The backend prunes this retirement when storage remains a raw scalar or inline tagged pair.
Loop reads and storage writes are joined separately. loop_flow.rs saves numbered break and continue edges for loops and switches before a branch restores its guard scope. A level-one switch continue joins the switch exit; higher levels still target the enclosing construct. Forward case checking and backward write analysis both place the default arm at its source position before following fallthroughs. Every case expression also inherits the default entry liveness, since failed labels can reach that body regardless of its source position. Only paths that reach the following statement determine its read view; writes on terminal loop paths still affect physical storage and the environment after the loop. Pending loop and switch jumps contribute to the input environment before a finally body is checked. finally_flow.rs also saves return and throw states before enclosing guards restore their fallthrough views. Operation-boundary queries reuse the optimizer exception summaries, excluding operands already evaluated in source order, so implicit throws and calls returning never contribute their current states. Case-insensitive free-function lookups use declaration-name indices shared across summary iterations, so builtin misses do not scan all user declarations. The checker maintains separate folded-name indices for signatures, declarations, variant groups and externs; publication and removal helpers update the matching index while lookups always read current signatures. Declared int returns record the EIR verification boundary for Mixed and float source representations as catchable TypeError edges. Callable effects, exception summaries, pruning and dead-code elimination share lexical return contracts, including closures. Untouched native parameters and declared callee results avoid unnecessary guards; user declarations take priority over builtin return metadata, and never calls have no return value to validate. First-match catch boundaries retain handled throws even without a finally block. Each handler starts from its own incoming states, and normal try/catch exits join before the finally or following statement. Direct catch-body coalescing can restore a non-null read. Exceptions raised inside a catch cannot be handled by a sibling catch. Known strict null guards exclude impossible normal exits only for stable locals; reference captures, aliases, globals, statics and eval remain conservative. Statement-owned string conversion, mutation and cleanup throws also feed the handler snapshots. A declared never call keeps its operation and operand throws but has no discarded result to retire afterwards. Iterator callbacks record both the entry and joined normal/continue back edges. Scalar overwrite cleanup uses a trusted retired type; externally writable bindings keep the conservative destruction summary. Pipe operands, reference-binding receivers/indices/calls, and typed, constant and static initializers visit the effects walk at their evaluation points. The fallback operand walk is exhaustive over AST variants: dynamic/generic construction, instanceof, clone, yields and object-class queries retain nested writes. Contextual callbacks defer literal closure/callable signature checks while still evaluating nonliteral arguments and bound callable receivers. Reference-source shape checks then reuse pure type queries without revisiting effects. Reference rebinding also accounts for retiring its old target. Assignment expressions expose the assigned value type independently of the merged storage contract. Nullsafe names/arguments join the skipped path; match results join each possible matching-label state, with failed-label effects carried to later labels/default. Nonlocal expression assignments include their setter/store exception edge. Function-like scopes isolate these snapshots, and nested finally effects update pending outer exits while preserving new exits raised by the finally itself. Finally effects also apply to those jumps afterwards, while a returning finally overrides them. After validating that all-input environment, normal continuation restores fallthrough-only reads for stable locals the finally cannot write, or whose recorded source-point stores preserve the same view; physical storage still includes every path. Compatible local copies form an invariant, and losing a source fact also drops dependent copies. Restoration only narrows the current view, so stronger facts established by the finally itself survive. Literal-only observations cannot mutate exposed reference cells. Reference outputs, captures, rebinding and opaque caller-local writes remain conservative. Container writes also block restoration of a stale element view. Direct indexed stores and appends merge element types after operand effects while retaining incoming nullable reads and physical slots. A normal-entry array guard can restore an updated non-null continuation view only when the finally has no whole-binding writes, reference invalidations or opaque caller-local effects. Native array builtin operands preserve a single concrete nullable array/hash payload through the existing typed container coercion, retaining element and key types. Runtime-layout PHP arrays still use the array-values path for value-only builtin operations. Indexed string-array chunking remains unsupported and produces a source-located lowering diagnostic on every target. Loop exits retain the entry alternative only when the construct can skip its body. Do-while and statically true or absent loop conditions join their actual exits; back edges of an unconditional loop contribute storage requirements without becoming exits. Each switch arm starts from the join of direct switch entry and a reachable preceding fallthrough, so an earlier break cannot narrow a later arm. An exhaustive switch also joins actual exits, and only its final arm can fall through out of the construct. Loop and switch exit snapshots track raw locals and fresh bindings as well as guarded or boxed locals, so writes on break and continue edges remain in inferred return and array element contracts even when a surrounding try, catch or switch restores its own entry snapshot. Property refinements remain excluded from loop snapshots. Rebinding drops the old storage and origin metadata, while its logical read joins are rebuilt from actual exits where the new binding remains defined. The previous entry binding does not override a foreach or destructuring binding. Strict local assignment replay is separate from these read joins: it starts from the incoming physical bindings, merges actual stores across arms, and excludes guard-only read refinements.
loop_backedges.rs selects writes that can reach another iteration. Before checking the body, compatible non-null stores preserve an entry refinement, while potentially nullable stores and normalized reference-call outputs widen it. The scan includes updates and repeated conditions, skips returned or broken paths, and counts nested loops and switches. Explicit reference aliases and by-reference closure captures created on continuing paths restore the retained nullable read type at entry. A for-update is checked after ordinary and continue body paths, in execution order, with their joined physical storage. New tail bindings are published afterwards; update-only locals are also seeded from fixed-point analysis for reads on later iterations. Counter increments in a for-update contribute their int|float semantic view to body reads from later iterations without evaluating the update early. This read view is separate from the numeric storage replay that can prove a bounded counter stays an integer. Nullable-store preservation applies only to an existing nullable binding; final checker facts must not impose that representation on a fresh first store.
Incoming references, explicit aliases and previously created by-reference captures keep fixed tagged cells. Their checked integer updates stay int on normal continuation and diagnose overflow, so the checker retains their nullable reference contract. Captures are recorded in source order and scoped to the current binding; a later capture does not change the storage of an earlier update.
The foreach checker records binding-retirement exceptions after publishing the value and then the key through stmt_check/foreach_bindings.rs. It captures the displaced value’s type after source evaluation, before this statement introduces a reference alias; aliases that already existed remain conservative. EIR fetches current and key before either binding is published, so iterator callbacks observe the old bindings. Concrete iterator method results transfer their returned owner into the boxed runtime cell. Fetched operands stay rooted through callbacks and stores, then retire before the displaced owner; each displaced value remains rooted until its replacement is visible to a throwing destructor. These edges exist even when a break or return prevents a back edge. Their snapshots mark the published names as present, while the ordinary loop-entry presence is preserved for a source that executes zero iterations. Literal-empty arrays add no binding-retirement edge. Concrete arrays with non-throwing element cleanup have no pre-binding iterator exception; operand effects retain their own source-point states. Object and opaque iterable callbacks, warnings, and potentially throwing source cleanup retain conservative pre-binding states.
Retirement pins read the physical frame occupant after an unset, rather than loading an object slot as raw null. A by-reference foreach reads its prior payload through the runtime reference representation on every backedge; first-entry cleanup clears the initial raw slot before that read. An Iterable read view over an existing boxed reference-local retains dynamic array dispatch. Concrete object and unboxed iterable sources keep their unsupported-by-reference diagnostic.
Object guards preserve a known subclass when probing its parent class or an implemented interface. The completed-store proof also recognizes the typed ArrayValues runtime operation: its broad catalog return describes argument-dependent element types, while successful execution always returns an array. Exception summaries are initialized only after successful declaration-schema validation, before callable bodies; malformed inheritance retains conservative exception flow and its normal diagnostics.
Nullable boxing alone does not establish a prior reference binding. A null-guarded ?iterable parameter without an earlier boxed-reference promotion retains the by-reference foreach diagnostic, including an Iterator whose first valid() result is false.
A guard-only read view leaving its lexical scope must contain only values representable by its logical input, using type_contains_values rather than coercive argument compatibility. NarrowedLocalOrigin::read_input retains the read before that individual guard, independently of the outermost physical assignment origin. A nested guard closes an incompatible unwritten input view before saving its own input. Exit snapshots retain the captured guard and physical storage alongside the contained read. A join inside an enclosing guard can recover that guard only for the same unwritten binding revision; real stores advance the revision, including stores made before a fresh nested guard. Catch and finally inputs use the guard scope at their try statement’s entry rather than the state left by a sibling path. This covers single and multiple continue edges while preserving contained nullable refinements, inner object capabilities and actual stores made before or inside a guard. Ternary and short-circuit regions also close incompatible views before their origin scopes end.
Conditional joins also accumulate guard-region stores from every alternative, including terminal paths. Publishing a read-only join retains its active guard origin while an actual store ends it. This keeps an inner conditional from treating its own read-view publication as a write to an enclosing guarded binding. Match labels, numbered arms and the default project their incoming snapshots into the original match-entry guard scope before restoring them. Failed-label effects still reach later labels and the default, and a changed binding revision prevents that projection from reviving a guard after a real store.
Lazy initialization (the singleton shape)
A completed $this->p = <non-null> or self::$p = <non-null> write re-establishes the fact for that place, recorded as the property’s declared type minus null (never the assigned expression’s type — a declared property coerces what it stores). When a single guarded clause both falls through and wrote its guarded place, the type after the if is the union of the then-branch exit fact and the guard complement instead of the pre-if type. Together those make PHP’s lazy-initialization idiom check:
class S {
private static ?S $inst = null;
public static function get(): S {
if (self::$inst === null) { self::$inst = new S(); } // then-path: S
return self::$inst; // else-path: S => S
}
}
The !isset(self::$inst), self::$inst !== null early-return, self::$inst ??= new S() and $this->p ??= ... spellings all reach the same fact. An intervening call still drops it, so a genuinely unsound program (self::wipe(); return self::$inst; — a TypeError under PHP) stays rejected.
Return-type validation is flow-sensitive against these facts: each return records the type it had where it was checked (Checker::flow_typed_returns), so a narrowing established halfway down a body is not applied to a return that executes before it.
Narrowing applies across if/elseif/else chains: each subsequent clause (and the else) sees the accumulated complement of the previous guards. A chain with no else whose every clause body always diverges (return, throw, exit()/die(), or a call to a function declared : never) narrows the statements after the entire if construct to the accumulated complement. This is what makes the common “overload” shape type-check:
function set($x): void { // $x inferred int|Foo from the call sites
if (is_int($x)) { $this->n = $x; } // $x is int here -> stored into an int property
else { $this->o = $x->run(); } // $x is Foo here -> method dispatched on its class
}
Narrowing preserves the variable’s runtime storage. Codegen coerces boxed Mixed values where the narrowed type is required, unboxing scalar uses and dispatching methods on Mixed/union receivers by runtime class id. For an interface receiver already stored as an object pointer, the checker carries the receiver type at each property read or method call into EIR. A guarded read can therefore select a concrete class’s property or a derived interface’s method while the local slot keeps its original interface type and ownership. The shared local-load storage predicate accepts these different object names as the same pointer representation, so codegen performs no conversion and cleanup treats the refined load as a borrowed slot view. These facts are keyed by body scope and source node; conflicting flow-sensitive checks disable the refinement for that use. Preliminary loop-storage inference omits guards and narrowed capture types, so it cannot publish receiver facts for the queried body or its lexical closures. Independently checked callees retain their own facts; the named-function context distinguishes a user function called main from the program entry scope. Receiver observations are retained when a property result is already narrowed and when isset() bypasses ordinary read inference. Silent probes choose their class and initialization check from the checked receiver view before any property read; ordinary reads still raise for uninitialized typed properties. Named, literal dynamic and runtime-name property reads, including their nullsafe forms, consume the same checked receiver view. Dynamic probes carry a nullable result and their shared slot-state guard yields null for an uninitialized typed property. Buffer probes retain their element type and use a raw missing-handle sentinel, since boxing a buffer discards the layout required by later element access. Reassigning a narrowed variable inside a branch replaces the narrowed binding with the assigned type, and it invalidates any property narrowings rooted at that variable.
Property assignments and unset() record the same checked receiver view. A write records its receiver before inferring the RHS, and lowering applies that view before choosing the destination type or evaluating the RHS. Runtime-name writes also refine the receiver before evaluating the name. Removal selects its supported storage shape and magic behavior from the checked view before lowering the receiver once. These operations retain the existing receiver leases, readonly checks and exception cleanup; they do not read a property’s value just to remove it.
Silent classification resolves instance and static property receiver types from class metadata before applying the checked view. Dynamic unset names use declared user/extern return types and retain existing builtin string hints, because a generic builtin contract may be wider than a checked string result.
Static write chains fetch their receiver after evaluating the RHS and runtime name. Those effects can replace the object after an instanceof guard, so delayed interface receivers use a temporary boxed runtime view instead of applying the earlier concrete-class fact. The static slot keeps its declared interface type. Each selected child transfers the receiver lease to the next view, preserving cleanup on both normal and exceptional paths.
Assigning inside a guarded region
A guard publishes its view by OVERWRITING the environment entry, which is right for reads but wrong for the store in $g = glob($dir . "/*.meta"); if ($g === false) { $g = []; }: merge_local_assignment_type saw false on the left, array<never> on the right, and reported cannot reassign $g from false to array<never> — on the commonest fallback idiom in PHP (issue #509). The binding is array<string>|false, which holds an empty array, and no slot is abandoned, so there was nothing to reject.
Checker::narrowed_local_origins carries the missing half. control_flow opens an entry for every name it narrows (enter_flow_narrowing), recording the type the binding held before the guard plus the view the guard published; a failed merge is re-measured against that origin, and if the binding holds the new value the store is accepted with the assigned type going into the environment. Three rules keep it honest:
- The origin is the outermost one. Nested guards keep the first entry’s origin, so a store inside
if (is_scalar($x)) { if (is_float($x)) { … } }is judged against$x’s own type rather than against another guard’s view. - The complement re-opens the view. An
if/elseif/elsechain narrows twice per guarded clause — to the target for its own body, then to the complement for the clauses after it — sorepublish_flow_narrowingrecords the second one and theelseside of the idiom is recognised too. - A store ends the guard’s authority.
record_store_over_flow_narrowingclears the view as soon as the region binds the name, so the SECOND store in a region is measured against what the first left behind. Without it,$a = 1; if (is_string($a)) { $a = "x"; $a = 2; }would be accepted outright instead of reaching the branch-divergentMixed-storage rule above and its--strict-localserror — comparing the view to the environment is not enough to tell the two apart, since the store in that example leavesstringbehind, which is also what the guard published. - And it travels outward. The enclosing region CONTAINS the closing one, so a store the inner one made is a store the enclosing one made:
exit_flow_narrowingclears the restored entry’s view too. That fact isNarrowedLocalOrigin::stored_in_regionrather thanview.is_none(), because a complement re-opens the view and the store still has to be remembered — including for a single-clauseifwith nothing after it, where the complement is published to an empty rest-of-chain. Without it, nesting was a way around the previous rule:if (is_string($a)) { if (is_float($a)) { $a = 1.5; } $a = 2; }was accepted while the same two stores written flat were not.
Entries are per body (enter_local_binding_scope takes the map, so a closure checked inside a guarded branch starts empty) and are closed when the construct ends, including when its complement is kept for the statements after it: those are ordinary straight-line code, where a store that does not fit takes the depth-0 re-bind path instead.
mixed_storage_scan already replayed a name’s stores against the BINDING rather than against each guard’s view (guard_region_is_transparent), so this brings the checker into line with the pre-scan rather than teaching it something new.
Diagnostics and warnings
The checker is no longer strictly first-error-only. Many passes now accumulate independent semantic errors and return them as a grouped diagnostic instead of aborting immediately on the first failure.
After successful checking, elephc also runs a warning pass over the AST (src/types/warnings/). Current warnings include:
- unused local variables and parameters
- unreachable code
- suspicious OOP declarations, such as
final privatemethods (never overridden, sofinalis meaningless outside__construct)
Warnings are returned through CheckResult and printed by the CLI without failing the compilation.
Checker errors and warnings carry only a Span. Each file the resolver or the autoload pass lexes gets its own source identity, and Span::register_source_path records which physical file that identity came from; print_error / print_warning prefer Span::source_path over an explicit file tag, so an entry-file tag cannot rename a position in another file. The pipeline registers the entry path before frontend processing, making positioned diagnostics print as kind[file:line:col]. Class schema errors (circular inheritance, extending a final class, duplicate or unknown parent classes) point at the class declaration.
The discarded autoload prelude probe restores only thread-local source IDs and anonymous-class numbering. Synthetic spans and hidden value-include temporaries use monotonic process-global counters, so a probe cannot recycle identities allocated by another compiler thread. Gaps left by discarded work do not affect PHP values or name resolution.
Lookup indexes
Large programs (thousands of classes) made several per-member scans of the whole class table dominate checking time. The checker keeps lazily built indexes for them, each stored with the classes.len() it was built for, so registering a class invalidates it:
class_children_index: direct subclasses per parent, used byclass_and_descendantsfor late-boundnew statictargets.method_impl_users_index: the classes whose method table dispatches a given(implementing class, method, is_static)to that implementation, to which each checked body’s inferred return is copied.property_sharers_index: the classes sharing a(property, declaring class)slot, retyped together by a constructor argument.loop_storage_scope_spans: the loop spansloop_storage_typesholds for each scope, so re-walking a scope clears only its own loop contracts.
Builtin, function and folded-map lookups try the exact key before the case-insensitive scan.
Where the checker sits in the optimizer pipeline
The type checker sits between an early folding pass and four post-check cleanup passes in src/optimize/:
fold_constants()runs first and simplifies scalar expressions that are already statically decidable.propagate_constants()runs after successful checking and pushes known scalar locals through conservative straight-line and merge shapes.prune_constant_control_flow()runs only after successful checking and warning collection, so dead branches can be removed without suppressing type errors or warnings that should still be reported.normalize_control_flow()runs after pruning and rewrites structurally equivalent control-flow shells into simpler AST shapes.eliminate_dead_code()runs after normalization and removes the leftover unreachable or non-observable statements.
That ordering is intentional. elephc is happy to rewrite 2 + 3 into 5 before checking, but it does not want an optimization pass to make broken code look valid by deleting it too early.
The global environment
Before checking user code, the type checker pre-populates the environment with built-in globals:
global_env.insert("argc", PhpType::Int);
global_env.insert("argv", PhpType::Array(Box::new(PhpType::Str)));
These correspond to PHP’s $argc and $argv superglobals.
Interface type checking
Before ClassInfo is built, the checker flattens trait composition through src/types/traits.rs, builds InterfaceInfo entries for every interface, and only then builds class metadata recursively.
pub struct InterfaceInfo {
pub interface_id: u64,
pub declaration_span: Span, // Span::dummy() for compiler-injected interfaces
pub parents: Vec<String>,
pub properties: HashMap<String, PropertyHookContract>,
pub property_order: Vec<String>,
pub method_decls: Vec<ClassMethod>, // source declarations retained for Reflection
pub methods: HashMap<String, FunctionSig>,
pub late_static_method_returns: HashMap<String, TypeExpr>, // exact syntax of `static`-typed returns
pub method_declaring_interfaces: HashMap<String, String>,
pub method_order: Vec<String>,
pub method_slots: HashMap<String, usize>,
pub static_methods: HashMap<String, FunctionSig>, // static interface methods (PHP 8.3+)
pub late_static_static_method_returns: HashMap<String, TypeExpr>,
pub static_method_declaring_interfaces: HashMap<String, String>,
pub static_method_order: Vec<String>,
pub constants: HashMap<String, Expr>, // interface constants (PHP 5.0+)
pub constant_types: HashMap<String, TypeExpr>, // PHP 8.3 typed constants
pub constant_declaring_interfaces: HashMap<String, String>,
pub final_constants: HashSet<String>, // PHP 8.1+ final constants
}
For each interface, the checker resolves interface extends interface transitively, rejects inheritance cycles, flattens required methods into a single signature map, and assigns a stable method ordering used by runtime metadata emission. properties records PHP 8.4 property hook contracts required by the interface, and constants carries interface constants inherited from parent interfaces, with PHP 8.3 declared constant types tracked in constant_types and PHP 8.1+ final constants in final_constants. static_methods records PHP 8.3+ static interface methods separately from instance methods: static dispatch is by class, so they take no vtable slot. Conformance checking requires a concrete implementing class to declare a compatible static method (Class {} must implement static interface method {}::{} otherwise); abstract classes may defer.
Interface methods declared to return PHP’s late-bound static type keep their exact return syntax in late_static_method_returns / late_static_static_method_returns. Return covariance is honored during conformance: an interface method whose required return names the interface itself (or via self/static) accepts an implementation that declares the implementing class’s own type, as long as the class implements the interface naming that return (directly or through a parent interface).
Class type checking
After interfaces are known, the checker builds each class so it sees parent-first property layout, inherited method signatures, abstract/final constraints, implemented interface contracts, and vtable slot assignments.
When the type checker encounters a ClassDecl, it:
- Registers the class in a
classes: HashMap<String, ClassInfo>map - Resolves the parent chain (
extends) and merges inherited metadata - Records each instance property with its type (declared when a property type is present, otherwise inferred from default values or constructor assignments) and a fixed offset in the inherited object layout
- Type-checks each method body with
$thisbound toObject(ClassName) - Builds
ClassInfocontaining instance and static property types, defaults, visibility maps, final property/method sets, signatures, declaring/implementation class maps, instance/static vtable slots, implemented interface lists, and constructor-to-property mappings
The ClassInfo struct:
pub struct ClassInfo {
pub class_id: u64,
pub declaration_span: Span, // Span::dummy() for compiler-injected classes
pub parent: Option<String>,
pub is_abstract: bool,
pub is_final: bool,
pub is_readonly_class: bool,
pub allow_dynamic_properties: bool, // #[\AllowDynamicProperties] (PHP 8.2)
pub constants: HashMap<String, Expr>, // user-declared class constants
pub constant_types: HashMap<String, TypeExpr>, // PHP 8.3 typed constants
pub constant_visibilities: HashMap<String, Visibility>,
pub final_constants: HashSet<String>, // PHP 8.1+ final constants
pub attribute_names: Vec<String>,
pub attribute_args: Vec<Option<Vec<AttrArgEntry>>>,
pub method_attribute_names: HashMap<String, Vec<String>>,
pub method_attribute_args: HashMap<String, Vec<Option<Vec<AttrArgEntry>>>>,
pub property_attribute_names: HashMap<String, Vec<String>>,
pub property_attribute_args: HashMap<String, Vec<Option<Vec<AttrArgEntry>>>>,
pub constant_attribute_names: HashMap<String, Vec<String>>,
pub constant_attribute_args: HashMap<String, Vec<Option<Vec<AttrArgEntry>>>>,
pub used_traits: Vec<String>,
pub trait_aliases: Vec<(String, String)>, // (alias, Trait::method)
pub properties: Vec<(String, PhpType)>,
pub property_offsets: HashMap<String, usize>,
pub property_declaring_classes: HashMap<String, String>,
pub defaults: Vec<Option<Expr>>,
pub property_visibilities: HashMap<String, Visibility>,
pub property_set_visibilities: HashMap<String, Visibility>, // PHP 8.4 asymmetric `set` visibility
pub declared_properties: HashSet<String>,
pub property_declared_slots: Vec<bool>, // per-layout-slot typed-declaration flags
pub final_properties: HashSet<String>,
pub readonly_properties: HashSet<String>,
pub reference_properties: HashSet<String>,
pub owned_reference_properties: HashSet<String>, // ref cells the object allocates/frees itself
pub promoted_properties: HashSet<String>,
pub property_reference_slots: Vec<bool>, // per-layout-slot by-reference flags
pub abstract_properties: HashSet<String>,
pub abstract_property_hooks: HashMap<String, PropertyHookContract>,
pub static_properties: Vec<(String, PhpType)>,
pub static_defaults: Vec<Option<Expr>>,
pub static_property_declaring_classes: HashMap<String, String>,
pub static_property_visibilities: HashMap<String, Visibility>,
pub declared_static_properties: HashSet<String>,
pub final_static_properties: HashSet<String>,
pub method_decls: Vec<ClassMethod>,
pub methods: HashMap<String, FunctionSig>,
pub static_methods: HashMap<String, FunctionSig>,
pub late_static_method_returns: HashMap<String, TypeExpr>, // exact syntax of `static`-typed returns
pub late_static_static_method_returns: HashMap<String, TypeExpr>,
pub callable_method_return_sigs: HashMap<String, FunctionSig>,
pub callable_array_method_return_sigs: HashMap<String, FunctionSig>,
pub method_visibilities: HashMap<String, Visibility>,
pub final_methods: HashSet<String>,
pub method_declaring_classes: HashMap<String, String>,
pub method_impl_classes: HashMap<String, String>,
pub abstract_methods: HashSet<String>, // declared abstract; codegen trims method_impl_classes to EMITTED bodies, so absence there does not mean abstract
pub vtable_methods: Vec<String>,
pub vtable_slots: HashMap<String, usize>,
pub static_method_visibilities: HashMap<String, Visibility>,
pub final_static_methods: HashSet<String>,
pub static_method_declaring_classes: HashMap<String, String>,
pub static_method_impl_classes: HashMap<String, String>,
pub abstract_static_methods: HashSet<String>,
pub static_vtable_methods: Vec<String>,
pub static_vtable_slots: HashMap<String, usize>,
pub interfaces: Vec<String>,
pub constructor_param_to_prop: Vec<Option<String>>,
}
vtable_methods / vtable_slots drive ordinary inherited instance dispatch, while static_vtable_methods / static_vtable_slots carry the parallel metadata used by static::method() late static binding. allow_dynamic_properties records the PHP 8.2 #[\AllowDynamicProperties] attribute so codegen can route undeclared property storage through a per-object side table. The *_attribute_names / *_attribute_args fields carry PHP 8 attribute metadata for the class, its methods, its properties, and its constants so the Reflection codegen path can materialize ReflectionAttribute objects. abstract_property_hooks records PHP 8.4 property hook contracts that concrete subclasses must satisfy, and property_set_visibilities records PHP 8.4 asymmetric write visibility (e.g. public private(set)) for properties whose write visibility differs from their read visibility. The per-slot vectors (property_declared_slots, property_reference_slots) follow the physical properties layout by index so hidden private parent slots keep their metadata when a child declares a same-named property.
Declaring and implementing classes
Two maps answer “whose method is this?”, and they answer different questions:
method_declaring_classes(andstatic_method_declaring_classes) names the class PHP REPORTS as declaring the method:ReflectionMethod::getDeclaringClass()and the class inCannot override final method X::m. An inherited method keeps its ancestor, and a trait method names the class that uses the trait.method_impl_classes(andstatic_method_impl_classes) names the class whose BODY a call runs, which is the method symbol dispatch tables and runtime hook tables point at. Codegen trims it to the bodies it actually emitted, so a missing entry does not mean the method is abstract (seeabstract_methods).
The two agree almost everywhere. They differ where a compiler-injected body keeps an inherited
final method’s declarer: the PDO prelude gives PDOException its own getCode() body, which
returns the SQLSTATE string, while PHP reports that method as the final Exception::getCode().
injected_exception_final_override (schema/classes/methods.rs) accepts that one dummy-span
override and records PDOException as the implementing class but Exception as the declaring
class; a source class, even one named PDOException, never takes that path.
Each consumer reads the map that matches its question:
| Consumer | Map | Why |
|---|---|---|
schema::classes::interfaces::validate_interface_method | declaring, implementing, and the class itself | the SQLSTATE getCode(): string|int contract is accepted wherever the inherited body comes from, so a subclass of PDOException is not re-validated against Throwable::getCode(): int |
AOT reflection (reflection_method_declaring_class_name) | declaring | what PHP prints |
eval reflection rows (eval_reflection_instance_method_declaring_class, eval_reflection_static_method_declaring_class) and eval attribute registration (eval_native_method_declaring_class) | declaring first, implementing as a fallback | eval must report the same owner as AOT, and attributes are keyed by that owner |
dispatch tables, _class_*_ptrs hook tables | implementing | the body that runs |
Constructor ownership
A PRIVATE method is not inherited, so ClassInfo::methods deliberately carries no
__construct entry on a descendant of a class that declares one. PHP still instantiates
that descendant through the ancestor’s constructor, and still NAMES the ancestor wherever a
constructor is reported. types::constructor_owner(classes, class_name) models both halves:
it walks the parent chain to the nearest class whose own methods map has the entry, and
returns the instantiated class itself whenever that map already has one — so an inherited
public or protected constructor (which IS copied into the descendant’s map) resolves exactly
as it did before.
Every consumer that asks “does this class have a constructor, and whose?” must go through
it, never class_info.methods.get("__construct") directly. Reading the map is right only
when the question is about the class’s own MEMBER LIST, which is a different question: PHP
reports method_exists('Child', '__construct') as false and get_class_methods('Child')
as empty for exactly this shape, while ReflectionClass::getConstructor() still finds the
ancestor’s. The two must not be conflated in either direction.
The consumers are spread across the pipeline, and each one that read the map directly was a distinct user-visible defect:
| Consumer | Question it asks |
|---|---|
checker::inference::objects::constructors | visibility, arity, and the class NAMED in both diagnostics |
ir_lower::expr::reflection_constructors | which class’s __construct a ReflectionMethod on the descendant resolves to |
codegen::lower_inst::objects::fixed_new / dynamic_factory | the constructor the allocation calls |
codegen::lower_inst::objects::reflection::names_constants | the synthetic member getConstructor() returns when the descendant’s own map has none |
codegen::lower_inst::objects::reflection::class_metadata | getConstructor()’s member and isInstantiable() |
codegen_support::runtime::data::user | the eval ReflectionMethod lookup row, keyed by the descendant and carrying the OWNER as its declaring class |
ir_lower::expr::object_construction and ir_lower::expr::reflection_new_instance ask the
same question for a different purpose — the signature whose defaults pad a new Child() or
ReflectionClass::newInstance() call — and both still read class_info.methods directly.
That is the defect issue #868 tracks; they join the table when it lands.
A descendant that declares its OWN constructor hides the ancestor’s, private or not: the walk
stops at the descendant, which is what makes new static() from the ancestor’s scope report
the DESCENDANT’s visibility.
Typed class constants (PHP 8.3)
src/types/checker/schema/class_constants.rs validates typed constant declarations on classes, interfaces, enums, and traits. Validation is deferred until all class-like schemas exist, so object and interface relationships named in constant types resolve. Declared types are recorded in constant_types; initializer values are checked strictly against the declared type apart from PHP’s allowed int-to-float widening, with a conservative Mixed inference accepted when an initializer cannot be narrowed statically. Inherited redeclarations must satisfy covariant type contracts, and constants declared final (PHP 8.1+) cannot be redeclared.
constants on ClassInfo and InterfaceInfo is a map and carries no order, so each schema also records constant_order, the names in PHP’s declaration order:
- a class lists its own constants as declared, then those its traits bring in (
src/types/traits/merge.rssorts a compatible redeclaration back to its own position); - an enum interleaves cases and constants by their source spans, since the parser keeps them in two lists;
- an interface lists its own constants, then each parent interface’s.
ReflectionClass::getConstants() / getReflectionConstants() build PHP’s full order from these per-class lists. That order is the class’s own names, then the parent chain, then the implemented interfaces, deepest ancestor first. The compiled path does this in src/codegen/lower_inst/objects/reflection/class_members.rs, and the eval bridge in src/codegen/eval_class_constant_helpers.rs, so compiled and eval() Reflection agree. A name missing from a recorded order still appears, after the ordered ones, sorted.
For abstract methods, the checker keeps the inherited signature but intentionally leaves the implementation-class entry unset until a concrete subclass provides a body. Concrete classes are rejected if any abstract or interface requirement remains unresolved after inheritance + trait flattening + interface conformance checks.
When checking property access ($obj->prop), the type checker validates that:
- The variable is an
Objecttype - The class has a property with that name
- The property is accessible (
public,protectedfrom the declaring class or a subclass, orprivateonly from the declaring class)
Nullsafe access ($obj?->prop, $obj?->method()) accepts object and nullable-object receivers. If the static receiver type is exactly null, the expression type is null without validating the skipped member. If the receiver is T|null, the checker validates the member against T and widens the result to include null.
Ordinary member access on a nullable union is also accepted when the union resolves to one concrete class. That keeps mixed chains such as $obj?->profile->address typeable: the null introduced by the earlier nullsafe segment remains in the inferred result, while codegen decides whether a later ordinary -> is skipped by the shared nullsafe branch or receives a real null and must warn/fatal like PHP.
When checking static property access (ClassName::$prop, self::$prop, parent::$prop, or static::$prop), the checker resolves the receiver to a class scope, verifies the static property exists, applies visibility rules against the declaring class, and enforces declared property types on assignment. Static property storage is keyed by the effective declaring class: inherited static properties share the parent slot until a subclass redeclares the property, at which point the subclass gets its own slot. Non-private redeclarations must keep invariant declared types, cannot reduce visibility, and cannot override final; private redeclarations are independent. Codegen handles late-bound static::$prop dispatch and reports a runtime fatal error if the called class resolves to a private redeclared slot outside the current method scope.
When checking property writes, explicitly declared property types stay fixed. Defaults, direct assignments, array writes, and constructor assignments routed through untyped parameters must be compatible with the declared property type. Nullable and union property types use the same boxed mixed runtime representation as typed locals, while untyped properties keep the existing inference and refinement behavior.
Constructor-promoted properties reach the checker as ordinary class properties plus synthetic constructor assignments produced by the parser. This lets promoted parameter type hints, defaults, visibility, readonly checks, and by-reference parameter validation reuse the same FunctionSig, property metadata, and constructor-to-property mapping used by handwritten constructor assignments. The checker records by-reference promoted properties in reference_properties, which codegen uses to store an alias pointer instead of an owned property value.
PHP 8.4 property hook contracts are represented as abstract property requirements on class metadata. Interface properties and abstract trait/class properties record separate get and set type obligations: get contracts are covariant, set contracts are contravariant, and get+set contracts are effectively invariant for ordinary properties. Concrete classes clear those abstract requirements when they redeclare a compatible instance property.
When checking method calls, it verifies the method exists, enforces method visibility (public, subclass-visible protected, declaring-class-only private), validates argument count and types against the method’s FunctionSig, resolves parent::method() against the immediate parent class, resolves self::method() against the current lexical class, and accepts static::method() as a late-static-bound static call against the current class hierarchy. First-class callable validation uses the same method metadata for static::method(...) and stable object receiver targets such as $obj->method(...).
Methods declared to return PHP’s late-bound static type (alone, nullable, or inside a union) keep their exact return syntax in late_static_method_returns / late_static_static_method_returns. At each call site the checker re-resolves that preserved syntax against the concrete receiver: $child->create() on a method inherited from a parent that returns static types as the child class, not the declaring class. The nominal FunctionSig return type stays the concrete declaring class or interface for compatibility checks.
When checking new ClassName(...), it also rejects interfaces and abstract classes before codegen.
Built-in coroutine and iterator classes
Throwable, Error, Exception, Fiber, and FiberError are registered as built-in class-like types before user code is checked. FiberError extends Error, matching PHP’s throwable hierarchy. Fiber method bodies are placeholders in ClassInfo: their signatures make calls type-checkable, while codegen intercepts construction, instance methods, Fiber::suspend(), and Fiber::getCurrent() and routes them to __rt_fiber_* helpers.
src/types/fibers.rs owns the additional static checks for Fiber callbacks. new Fiber(...) accepts closures, known callable variables, first-class callables, runtime string callbacks, static-method callable arrays, stored or literal instance-method callable arrays such as [$object, "method"], runtime-selected callable arrays such as [$object, $method], and invokable-object expressions such as new Job(). Literal string callbacks are resolved to user-function, builtin, extern, or public static-method signatures when possible; dynamic string variables defer descriptor selection to codegen. Stored instance-method callable-array variables are checked through the same first-class callable signature path, while codegen binds the receiver from the array slot itself. The visible callback parameter count is capped at seven start arguments, and by-reference callback start parameters are rejected when the signature is statically known. Closure captures and receiver environments live in callable descriptor capture slots rather than in Fiber’s visible start-argument area. Values moving through start(), resume(), suspend(), and getReturn() are typed as boxed mixed.
Iterator, IteratorAggregate, and the final built-in Generator class are injected by src/types/checker/builtin_iterators.rs. Generator implements Iterator and carries placeholder method bodies for current, key, next, valid, rewind, send, throw, and getReturn; codegen intercepts those methods and routes them to __rt_gen_* helpers. yield_validation marks any function or closure body containing yield as returning Object("Generator"), while still allowing declared return types compatible with Generator, Iterator, Traversable, or iterable.
That coercion is one-way, and reading it backwards is a bug. A checked return type of Object("Generator") means either that the body holds a yield or that the author declared : Generator — and the second is not a generator at all:
function inner(): Generator { yield 1; yield 2; }
function factory(): Generator { return inner(); } // an ordinary function
factory holds no token, so PHP runs its body to completion and hands back the object inner() produced. Lowering used the checked return type as its generator-ness signal and compiled factory as a coroutine, which made return inner() the value getReturn() reports rather than the function’s result: iterating it never terminated (issue #1086). The checker records the syntactic fact on FunctionSig::is_generator from the source body, and ir_lower::function reads that bit. A later body scan can still add generator-ness; a declared : Generator return cannot.
The token stays in the body because optimize::generator_bodies::rewrite_preserving_yield refuses a rewrite that would delete a body’s last yield, including pre-check target folding. Before that guard existed, the declared type was the fallback that recovered generator-ness after a fold had removed the token — which is how the two questions came to be conflated.
Output: CheckResult
The type checker produces a CheckResult (defined in src/types/result.rs):
pub struct CheckResult {
pub global_env: TypeEnv, // variable name → type
pub functions: HashMap<String, FunctionSig>, // function name → signature
pub function_attribute_names: HashMap<String, Vec<String>>, // PHP 8 attributes on functions
pub function_attribute_args: HashMap<String, Vec<Option<Vec<AttrArgEntry>>>>,
pub callable_param_sigs: HashMap<(String, String), FunctionSig>, // (function, param) → callable signature
pub(crate) return_alias_summaries: ReturnAliasSummaries, // proven return-to-parameter storage aliases
pub callable_return_sigs: HashMap<String, FunctionSig>, // function → returned callable signature
pub callable_array_return_sigs: HashMap<String, FunctionSig>, // function → returned callable-array element signature
pub interfaces: HashMap<String, InterfaceInfo>, // interface name → interface info
pub classes: HashMap<String, ClassInfo>, // class name → class info
pub enums: HashMap<String, EnumInfo>,
pub packed_classes: HashMap<String, PackedClassInfo>,
pub extern_functions: HashMap<String, ExternFunctionSig>,
pub extern_classes: HashMap<String, ExternClassInfo>,
pub extern_globals: HashMap<String, PhpType>,
pub required_libraries: Vec<String>,
pub warnings: Vec<CompileWarning>,
pub throw_access_sites: HashMap<Span, ThrowAccessInfo>, // access violations lowered to runtime Error throws
}
Two maps of builtin call results, and why they are two
A builtin whose result depends on its arguments cannot be typed from its declaration.
array_slice() returns an associative array for an associative source and an indexed one
otherwise; the registry declares array for both. Only the contract knows, so lowering asks the
checker what it decided for that call — by SPAN.
builtin_call_types is that map, keyed by the span of whatever call the checker was inferring.
For a builtin written out at its own call site, the span is the call and the entry is its result.
That is not true of every span. call_user_func($f, …) with a VARIABLE callee records
call_user_func’s own result — Mixed, because the checker cannot resolve $f — while lowering
DOES resolve it statically and calls the builtin directly. Reading the shared map there labels a
bool-returning builtin’s raw result as Mixed, and the consumer dereferences it as a pointer.
That is a segfault, and it is why the per-span lookup is absent from the static-callable path.
first_class_builtin_call_types is the second map, written only where the checker resolved the
SAME callee lowering will. The static-callable lowering reads that one and nothing else. Keeping
them apart is what lets a callable-dispatched builtin keep its checked result without reopening
the collision (issues #1042, #1092).
The line between the two maps is resolved by the checker from the same syntax lowering reads, not spelled a particular way:
| callback | recorded | why |
|---|---|---|
$fn($assoc, 1, 2) where $fn = array_slice(...) | yes | the target is in first_class_callable_targets, which lowering mirrors in static_callable_locals |
array_slice(...)($assoc, 1, 2) | yes | the callee is the syntax itself |
call_user_func("array_slice", …) | yes | a literal name, resolved extern → builtin → user on both sides |
call_user_func(array_slice(...), …), call_user_func($fn, …) | yes | the same two resolutions, reached through call_user_func |
call_user_func_array(…, [<literal>]), all three callback forms | yes | same lowering entry point; a literal argument array is the only shape whose elements lowering can hand to the builtin |
call_user_func($f, …) where $f is runtime-opaque | no | the checker cannot resolve $f; lowering may |
call_user_func_array(…, $dynamic) | no | the arguments are not known, so the contract cannot be asked with them |
| a name that is also an EXTERN function | no | lowering resolves the extern first and never reads this map for it |
$fn = "array_slice"; $fn($assoc, …) | no | lowering resolves that string statically; the checker does not track string-literal callable locals at all |
A call with no entry takes RuntimeFnId::fallback_result_type in lowering. For array_slice()
that answer is built from the operand types alone: an associative source keeps its layout and a
boxed mixed/array source gets the boxed PHP array type, so the string-literal spelling above
slices a hash instead of refusing it (issue #1347).
A call_user_func_array(…, $dynamic) call reaches the builtin’s callable wrapper, whose ABI
comes from RuntimeFnId::refine_runtime_callable_wrapper_sig. The array_slice() and
array_reverse() wrappers keep their $preserve_keys parameter: they take a boxed source, whose
result is the boxed PHP array whatever the flag says, so the boxed lowering reads the flag at run
time instead of the wrapper silently renumbering the keys.
When the contract REJECTS the arguments at a recorded site, the checker reports the rejection only
if it is a compile-time literal requirement (CompileErrorKind::AotLiteralRequired: a
preserve_keys flag, a constant or class name, a str_word_count() format). Every lowering of the
callee needs that argument at compile time, so the direct call’s diagnostic is the right one
(issue #1346). Any other rejection is the direct call’s view of its argument types, which can be
narrower than the callable ABI: $f = array_reverse(...); $f($mixed) compiles and runs although
array_reverse($mixed) is refused, so such a rejection falls back to the generic signature path.
This is passed to the code generator, which uses it to:
- Allocate the right amount of stack space per variable
- Choose the correct registers and instructions
- Emit proper type coercions
- Carry FFI declarations and linker requirements into codegen
Error examples
int $x = 42;
$x = "hello";
// Error: Type error: cannot reassign $x from int to string
// (a DECLARED type stays strict in every mode; an untyped local instead warns by
// default and only errors under --strict-locals — see "Local retyping and strict
// locals mode" above)
strlen(42);
// Error: strlen() argument must be string
unknown_func();
// Error: Undefined function: unknown_func
substr("hello");
// Error: substr() takes 2 or 3 arguments
Each error includes the exact line and column, thanks to the Span carried through from the lexer.