PHPantom — Type Inference¶
Type resolution gaps: generic resolution, conditional return types, type narrowing, PHP version features, and stub attribute handling. Items that are purely about completion UX or stub metadata extraction live in completion.md.
Items are ordered by impact (descending), then complexity (ascending) within the same impact tier.
| Label | Scale |
|---|---|
| Impact | Critical, High, Medium-High, Medium, Low-Medium, Low |
| Complexity | Low (mechanical/boilerplate, no design decisions), Medium (self-contained, follows an existing pattern), Medium-High (spans modules, some new design), High (shared/core subsystem, correctness or performance tradeoffs), Very High (cross-cutting architecture, wide blast radius) |
T26. Class constants named as docblock types (Foo::BAR, Foo::BAR_*)¶
Impact: Medium · Complexity: Medium
A class constant named in a type position is not read at all: @param
Foo::BAR $x, @param self::FOO|self::BAR $x and @param C::class|D::class
$x all stay as unresolved text (or fall back to the native hint), where
PHPStan and Psalm give the constant's value ('bar', 'bar'|'foo',
'App\C'|'App\D'). The same constants are read when they are the operand
of key-of<>/value-of<>, through constant_operand_shape, so the value
lookup exists; the bare reference just never reaches it. Found porting
Psalm's ConstValuesTest and ReconcilerTest; the assertions are // SKIP
in tests/psalm_assertions/const_values.php and
tests/psalm_assertions/type_reconciliation_reconciler.php.
Falling back to the native hint is not harmless. A method declared
@return self::STATUS_* over a native int reads as int, so passing its
result to a parameter typed 1|2 is reported.
The wildcard form resolves a pattern like Foo::BAR_* to the union of all
matching constant types on the class. PHPStan supports this syntax in
docblock type strings:
class Status {
const STATUS_ACTIVE = 1;
const STATUS_INACTIVE = 2;
const STATUS_PENDING = 3;
}
/** @param Status::STATUS_* $status */
function setStatus(int $status): void { ... }
// $status should resolve to 1|2|3
When the type engine encounters a constant pattern containing *,
it should:
- Resolve the class (
Status). - Enumerate all constants matching the glob pattern (
STATUS_*). - Build a union of their literal types.
References:
- PHPStan: ConstantWildcardType / constant enum resolution.
- Phpactor: GlobbedConstantUnionType.
T34. static::CONST over-narrows to the declaring class's value¶
Impact: Medium · Complexity: Medium-High
Late static binding is ignored for class constants: static::CONST
resolves to the value declared on the current class, even though a
subclass may redeclare it. This is unsound in the false-positive
direction, since the narrowed literal can drive a bogus argument-type
mismatch or mark a live match arm dead.
class Foo {
const NO_TYPE = 1;
/** @var string */
const TYPE = 'foo';
public function doFoo(): void {
self::NO_TYPE; // 1 (correct)
static::NO_TYPE; // 1 (PHPStan: mixed — a child may redeclare it)
static::TYPE; // 'foo' (PHPStan: string — a child must respect @var)
}
}
PHPStan's model: self:: always yields the declared value. static::
(and $this::) yields the value only when it cannot be overridden, i.e.
the class is final or the constant is final/@final; otherwise it
widens to the constant's declared type, or mixed when the constant is
untyped.
Where to change: class_expression_name (src/class_lookup.rs) maps
Expression::Static to the current class exactly like Expression::Self_,
and $this:: lands in the same place through the expression branch.
class_constant_type (type_engine/call_resolution/return_types.rs) then
always prefers the initializer's value and never asks whether the class or
the constant is final.
Tests to update once fixed: the static::/$this:: assertions in
upstream's nsrt/class-constant-types.php were dropped when
tests/phpstan_nsrt/class-constant-types.php was ported (only the
self:: cases survive); port them back. The static::/$this::
assertions in tests/phpstan_nsrt/class-constant-native-type.php are
// SKIP against this item.
T29. Definite vs possible variable existence tracking¶
Impact: Medium · Complexity: High
PHPantom currently treats all assigned variables as definitely in
scope. This causes false negatives: a variable assigned only inside
one branch of an if (without the other branch) is treated as
always available after the if. The undefined-variable diagnostic
(diagnostics/undefined_variables/) only checks that some write
precedes the read, whichever branch it sits in.
Fix: Split variable tracking into two maps:
vars_in_scope— variables with a definite type (assigned on all code paths reaching this point).vars_possibly_in_scope— variables that might exist (assigned in only one branch). Accessing these without a guard could be flagged as "possibly undefined."
Psalm's Context uses exactly this split. The vars_in_scope map
holds Union types for definitely-typed variables. The
vars_possibly_in_scope map is a boolean set tracking variables that
might exist. After an if/else where only one branch assigns $x,
$x moves from vars_in_scope to vars_possibly_in_scope (or is
removed from vars_in_scope and added to vars_possibly_in_scope).
Additionally, contextual flags like inside_isset and
inside_conditional should suppress diagnostics about undefined
variables in those positions (accessing $x inside isset($x) is
intentional).
Design:
- Add a
possibly_defined: HashSet<SmolStr>alongside the existing variable type map in the forward walker state. - When merging branches (if/else, try/catch), variables assigned in
only one path move to
possibly_defined. - Hover shows
T|undefinedor similar annotation for possibly-defined variables. - Future diagnostic (D-series) can warn on access of possibly-undefined variables.
Knowing that a target is definitely set also settles ??=: on a target
that is set and never null (a non-nullable parameter, a required shape
key) the fallback can never be assigned, so $string ??= 1 is still
string. Today the fallback is always added; the assertions in
tests/phpstan_nsrt/coalesce-assign.php are // SKIP against this item.
References:
- Psalm: Context::$vars_in_scope and Context::$vars_possibly_in_scope
(Psalm\Context)
T44. A single enum case has no type¶
Impact: Medium · Complexity: High
enum Suit { case Hearts; case Spades; case Clubs; }
function f(Suit $s) {
if ($s === Suit::Hearts) {
} elseif ($s === Suit::Spades) {
} else {
$s; // PHPStan: Suit::Clubs; PHPantom: Suit
}
}
Suit::Hearts resolves to the enum class, so narrowing by comparing
against cases can only ever say "some Suit". PHPStan and Psalm give each
case its own type, a subtype of the enum: comparing narrows to the cases
left, a match over the rest is known exhaustive, a readonly property
assigned one case keeps it, and ->value/->name on it are the case's
literal values. Today ->value and ->name on any Suit read as the
union of every case's backing value and name, since nothing narrower than
the enum is known.
The type model needs an enum-case variant (or a literal kind for it) that
is a subtype of its enum in is_subtype_of, prints as Enum::CASE, and
joins back into the enum when every case is present. Narrowing by ===
and instanceof then subtracts cases the way it subtracts union members,
and a branch that has compared away every case holds never.
D24
depends on this: its second example only stays quiet if $s->value === 'H'
removes Suit::Hearts from $s, which is exactly this case subtraction.
Since value-of<…> over an enum is evaluated, the gap also produces false
positives. A case passed to a template binds the template to the whole
enum, so the operator gives every case's value:
/**
* @template T of Suit
* @param T $case
* @return value-of<T>
*/
function backingValue(Suit $case) { return $case->value; }
/** @param 'hearts' $value */
function acceptsHearts(string $value): void {}
acceptsHearts(backingValue(Suit::Hearts)); // reported: 'spades' does not satisfy 'hearts'
A case held in a variable loses its value the same way: after $case =
Suit::Hearts;, acceptsHearts($case->value) is reported too. Found
running the php-typing-conformance suite
(phpdoc_advanced_fallback_value_of_template_enum.php).
The ported copies of PHPStan's Rules/Comparison/data/bug-8485.php,
Rules/Comparison/data/bug-9499.php and
Rules/Methods/data/return-type-class-constant.php under
tests/phpstan_data/ carry // SKIP assertions against this item.
T46. Template arguments are not inferred from how the object is used later¶
Impact: Medium · Complexity: High
/** @template T */
class Box {
/** @param T $item */
public function add($item): void {}
/** @return T */
public function first() {}
}
$box = new Box();
$box->add(new Foo);
$box->first()-> // PHPStan 2.3: Foo; PHPantom: mixed (`Box<mixed>`)
A new whose constructor leaves a template open erases it to its default,
then its bound, then mixed (default_type_arg in
inheritance/generics.rs, reached from instantiate_class in
call_resolution/template_binding.rs), and members are substituted with
that immediately. PHPStan 2.3 keeps the template open and fills it from
the rest of the body. It ships behind the unresolvedTemplateArguments
bleeding-edge flag, so it is not yet on by default there either.
How PHPStan does it. The open template becomes a placeholder type
(UnresolvedTemplateArgumentType) that answers every type question as its
default or bound, so nothing downstream needs to know about it. While the
body is walked, facts about each placeholder accumulate:
- a value flows into it (
add(T $v)called withFoo,offsetSeton anArrayAccess): a lower bound; - the object is passed to, returned as, or assigned to something declared
Box<int>: a send, with that position's variance; - the object is passed to
mixedor an untyped slot: it escaped, and the placeholder falls back to default or bound.
Lower bounds are unioned (each first checked against the template's bound,
so add('x') on T of object leaves Box<object>). An invariant send
wins over lower bounds. Invariant sends between two placeholders unify
them, so $a->add(1); $b->add('x'); join($a, $b); gives both
Box<1|'x'>.
The body is then walked a second time with the solved arguments. That is
where the 1-3% cost comes from: statements before the first new replay
from a recording, and a later statement is re-walked only if it holds a
site or mentions a variable whose type changed (a syntactic check, cached
on the AST node). Everything else replays its recorded scope delta. The
second walk stops early once no variable differs and no site remains.
(StatementsHandler::processBodyStmtNodesTwoPass,
Analyser/Generics/TemplateArgumentSolver.php.)
What this means for an LSP. In PHPStan an earlier line's type can
depend on a later line: $box->first() hovered on the line before
takesInts($box) reads int. Our cursor-bounded walk stops at the cursor's
statement, so hover would read mixed there and int below, while
diagnostics (which walk the whole body) would disagree with hover. The
facts therefore have to come from a cursor-free pass over the whole body
(ctx.with_cursor_offset(u32::MAX), as by_ref.rs, foreach.rs and
while_for.rs already do for discovery), cached per body and content
version so hover and completion don't pay for a full walk per request. The
solved arguments seed the cursor walk before any snapshot is recorded.
Inferring from code below the cursor means half-typed code can flip a type while the user is typing. Two rules keep that stable: union lower bounds instead of PHPStan's "first invariant send wins" (which depends on order), and never narrow below default or bound from a lone escape.
First slice. new with no arguments that bind the template, assigned
to a plain variable; lower bounds from template-typed method parameters
and offsetSet; union merging; no cross-variable unification. That covers
the common "empty collection, then fill it" shape. Returns, property
writes and sends to typed parameters can follow.
Shared work with T47. Both need the same machinery: a cursor-free fact
pass over the body, a per-body cache, and a "does this statement mention
one of these variables" filter to limit the re-walk. assignment_deps.rs
already collects the variables a statement reads and writes
(collect_rhs_variables, collect_assignment_target_vars) and has a
convergence check (scope_has_changes); seed_by_ref_capture_fixed_point
in by_ref.rs is the closest existing probe-then-reseed pattern. Build it
once, for whichever of the two lands first.
Where to look: PHPStan 2.3's src/Type/Generic/UnresolvedTemplateArgumentType.php,
src/Analyser/Generics/, NewHandler::unresolvedArgumentList(), and the
tests/PHPStan/Analyser/nsrt/template-argument-*.php files. Ours:
rhs_resolution/instantiation.rs, call_resolution/template_binding.rs,
forward_walk/mod.rs (resolve_in_method_body, next to the existing
whole-body pre-passes seed_static_locals and
try_generator_yield_inference), forward_walk/assignment.rs
(process_expression_statement, where receiver calls are already visited).
T47. A closure assigned to a variable gets no parameter types from where it is used¶
Impact: Medium · Complexity: High
$byName = function ($a, $b) {
return $a-> // PHPStan 2.3: User; PHPantom: nothing
};
usort($users, $byName); // list<User>
A closure literal passed straight to a typed callable parameter already
gets its parameter types, generics included (try_enter_closure_expr →
infer_callable_params_for_call in forward_walk/closures.rs and
callable_inference.rs). Assigned to a variable first, it gets nothing:
the assignment branch passes no inferred types, and the cursor walk stops
before the statements that use it. This is the same blind spot from the
other direction as T46, and the payoff is the same: completion and hover
on an untyped parameter inside the body. PHPStan 2.3 ships it behind the
closureSignaturesFromUsages bleeding-edge flag.
What PHPStan infers from. Direct calls ($f(1); $f(2); gives 1|2),
passing the variable to a typed or generic callable parameter (usort,
array_map, array_filter, $collection->map($f), taking the parameter
type after template resolution), aliases and ternaries ($d = $f; $d(1);),
closures stored in array offsets or lists and called from there, and
return $f / a typed property / @var against a declared Closure(int):
void. All observed argument types are unioned. If the closure escapes
(passed to mixed, bare callable or Closure, call_user_func, a
by-ref parameter, global, compact/extract/get_defined_vars, $$),
or the union doesn't fit the declared type, the declared type stands. A
body is only observed when nothing outside it can reach its variables
(isClosedBody).
The mechanism is the two-pass walk described in T46: an untyped parameter
starts as a placeholder that remembers its closure, and since it is an
ordinary type it flows through variables, array shapes and foreach without
special cases. Call sites add lower bounds, typed targets add bounds, and
the second walk re-enters the closure body with the solved types.
(ClosureSignatureInference, TemplateArgumentObserver,
TemplateArgumentSolver::resolveClosureSignatureObservation; tests in
nsrt/closure-signature-from-usages*.php.)
Plan.
1. When try_enter_closure_expr meets $v = <closure> (or an array
offset or list element) with the cursor inside it and untyped
parameters, scan the rest of the enclosing statement list for uses of
$v and its aliases. Run the syntactic escape check first and give up
on any hit.
2. For a direct call $v(...), resolve the arguments at that position.
For $v passed as an argument, reuse infer_callable_params_for_call
on that call to get the callable parameter types, so usort,
array_map and ->map() come for free.
3. Union the observations, keep the declared hint unless the union fits
inside it, and feed the result through the existing inferred_params
slot of try_enter_closure_body / seed_closure_params. Run the same
inference from walk_closures_in_expr so diagnostics and completion
agree.
4. Later: give the variable's own type the inferred signature
(infer_closure_literal_type), so $f('x') and ->map($f) see it.
Resolving each use's arguments must go through the shared walker (a
cursor-free walk up to that offset, per T46's shared machinery), not a
separate resolver. Lower-value cases can wait: use (&$x) fixed points,
nested closures typed from an expected return type, generator escapes.
Where to look: ours: forward_walk/closures.rs,
forward_walk/callable_inference.rs, diagnostic_walk.rs
(walk_closures_in_call, seed_closure_params), rhs_resolution/calls.rs
($f() reads only the variable's callable return type today).
T41. A reading of the callee's body overrides a declared @param-out¶
Impact: Low-Medium · Complexity: Low
/**
* @param-out list<string> $lines
*/
function readInto(string $path, ?array &$lines = null): int {
$lines = [];
// … filled through a helper the walk cannot follow …
return 0;
}
readInto($path, $lines);
$lines; // array{}, where the tag promises list<string>
The tag is read: ParameterInfo::param_out_type holds it and
ParameterInfo::out_type() prefers it over the declared type and the
null-default heuristic. But effective_out_type
(type_engine/call_resolution/out_param.rs) then lets a reading of the
callee's body replace any declared out type it is a subtype of, the tag
included, so a body the walk only partly follows hands the caller a type
narrower than the author promised.
Fix: when the parameter carries an explicit @param-out, return it
without reading the body. The body reading should only sharpen a type the
author did not spell out for the write.
T43. self::TypeAlias inside @extends's generic argument is not resolved¶
Impact: Low · Complexity: Medium
Found while porting Mago's issue_870.php. A class can declare its own
@type/@phpstan-type alias and hand it to its own parent's template
parameter via self:::
/** @template TData as array<string, mixed> */
abstract class Car {
/** @return TData */
abstract function getData(): array;
}
/**
* @type DataArray = array{'Gewicht': int}
* @extends Car<self::DataArray>
*/
class RedCar extends Car {
public function getData(): array { return ['Gewicht' => 1000]; }
}
getData()'s inherited @return TData should resolve to the
DataArray shape (offering Gewicht for array-key completion), but it
reads as the unresolved RedCar::DataArray, so TData never gets the
shape and completion sees only the untyped array declared return.
Bare alias names in extends_generics/implements_generics are already
substituted (type_engine/types/aliases.rs), but the match is an exact
comparison against a Named type, so self::DataArray never matches it,
and qualify_self_constant (php_type/transform.rs) reads it as a class
constant instead.
Fix: when substituting a generic argument off @extends/@implements/
@template-implements (parsed by extract_generics_tag in
docblock/templates.rs), recognise a self::Identifier that names one of
the declaring class's own type_aliases and resolve it through
resolve_type_alias_typed with that class as owning_class_name, before
substituting it into the parent's template. Mago's issue_870.php can be
ported once this lands.