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) |
T3. Property hooks (PHP 8.4)¶
Impact: Medium · Complexity: Medium-High
PHP 8.4 introduced property hooks (get / set):
The mago parser (v1.8) already produces Property::Hooked and
PropertyHook AST nodes, and the generic .modifiers(), .hint(),
.variables() methods mean hooked properties are extracted for basic
completion. However:
- Asymmetric visibility (
public private(set) string $name) is not recognised — thesetvisibility is ignored, so filtering may incorrectly allow setting a property that should be write-restricted.
Fix: Parse the set-visibility modifier into a new
set_visibility field on PropertyInfo.
Asymmetric visibility (also PHP 8.4 / 8.5)¶
Separate from hooks, PHP 8.4 allows asymmetric visibility on plain and promoted properties. PHP 8.5 extended this to static properties.
class Settings {
public private(set) string $name;
public function __construct(
public protected(set) int $retries = 3,
) {}
}
PHPantom currently extracts a single Visibility per property.
Completion filtering uses this to decide whether a property should
appear. A public private(set) property should appear for reading
from outside the class but not for assignment contexts.
Add an optional set_visibility: Option<Visibility> to
PropertyInfo. Populate it from the AST modifier list (the parser
exposes the set-visibility keyword). Completion filtering does not
currently distinguish read vs write contexts, so the immediate fix
is just to store the value; context-aware filtering can follow later.
This shares the same set_visibility field as the hooked-property
fix above, so both should be implemented together.
T4. Non-empty-* type narrowing and propagation¶
Impact: Low-Medium · Complexity: High
PHPStan tracks non-empty-string and non-empty-array through
built-in functions. These narrowings don't directly enable
class-based completion, but they improve hover type display and
would catch bugs if we add diagnostics. The first three sub-items
share the same implementation pattern (function-name-triggered type
narrowing in conditions or return types) and should be implemented
together.
String containment narrowing. When str_contains($haystack,
$needle) appears in a condition and $needle is known to be a
non-empty string, narrow $haystack to non-empty-string. Same
for str_starts_with, str_ends_with, strpos, strrpos,
stripos, strripos, strstr, and the mb_* equivalents.
See StrContainingTypeSpecifyingExtension in PHPStan.
Count narrowing. if (count($arr) > 0) or
if (count($arr) >= 1) narrows $arr to non-empty-array.
PHPStan handles a full matrix of comparison operators and integer
range types against count() / sizeof() calls. See
CountFunctionTypeSpecifyingExtension.
String function propagation. Passing a non-empty-string to
addslashes(), urlencode(), htmlspecialchars(),
escapeshellarg(), escapeshellcmd(), preg_quote(),
rawurlencode(), or rawurldecode() should return
non-empty-string. See NonEmptyStringFunctionsReturnTypeExtension.
Element writes produce a non-empty array. After $a[$k] = $v or
$a[] = $v the array holds at least one element, so PHPStan reports
non-empty-array<K, V> / non-empty-list<V>. We report the
possibly-empty spellings, which loses the guarantee that reset(),
array_pop() and friends return a value rather than false.
Emitting the non-empty spelling from the write helpers
(merge_push_type, merge_keyed_type in
type_engine/variable/resolution.rs) is a two-line change, but it is
only half the work: every place that joins branches has to relax the
guarantee again when one side never ran. A foreach whose body writes
a key merges the pre-loop scope back in at exit, and today that join
either drops the array{} alternative outright (reporting
non-empty-array after a loop that may not have iterated at all) or
keeps it as a separate union member (array{}|non-empty-list<string>,
whose offset reads then carry a spurious null). Both spellings are
wrong; the join has to widen non-empty-X to X instead. So the work
is a possibly-empty widening at each join point, not a change to the
write helpers.
T5. Fiber type resolution¶
Impact: Low · Complexity: Medium
Generator<TKey, TValue, TSend, TReturn> has dedicated support for
extracting each type parameter (value type for foreach, send type
for $var = yield, return type for getReturn()). Fiber has no
equivalent handling — Fiber::start(), Fiber::resume(), and
Fiber::getReturn() don't resolve their generic types.
PHP userland rarely annotates Fiber with generics (unlike Generator),
so this is low priority. If demand appears, the fix would mirror the
Generator extraction in docblock/types.rs.
T6. Closure::bind() / Closure::fromCallable() return type preservation¶
Impact: Low · Complexity: Medium-High
Variables holding closure literals, arrow functions, and first-class
callables now resolve to the Closure class, so $fn->bindTo(),
$fn->call(), etc. offer completions. The remaining gap is
preserving the closure's callable signature through Closure::bind()
and resolving Closure::fromCallable('functionName') to the actual
function's signature as a typed Closure. This is relevant for DI
containers and middleware patterns but is a niche use case.
See ClosureBindDynamicReturnTypeExtension and
ClosureFromCallableDynamicReturnTypeExtension in PHPStan.
T10. Ternary expression as RHS of list destructuring¶
Impact: Low · Complexity: Medium
List destructuring ([$a, $b] = expr) resolves element types when
the RHS is a function call returning an array shape, or a simple
array literal. When the RHS is a ternary expression whose branches
are array literals or array-shape-returning calls, the resolver
doesn't drill into the branches to union the element types.
[$a, $b] = $cond ? [new Foo(), new Bar()] : [new Bar(), new Foo()];
$a-> // should see Foo|Bar members
Fixture to activate:
assignment/list_destructuring_conditional.fixture
phpactor ref: assignment/list_assignment.test
T11. Nested list destructuring¶
Impact: Low · Complexity: Medium
Nested destructuring like [[$one, $two]] = $source is not resolved.
When the RHS has a type like array{array{Foo, Bar}}, the outer
destructuring peels the first dimension but the inner destructuring
doesn't resolve individual elements.
/** @return array{array{Foo, Bar}} */
function getPair(): array { return [[new Foo(), new Bar()]]; }
[[$one, $two]] = getPair();
$one-> // should see Foo members
Fixture to activate:
assignment/nested_list_destructuring.fixture
phpactor ref: assignment/list_desconstruct_nested.test
T13. Closure variables lose callable signature detail¶
Impact: Low-Medium · Complexity: Medium-High
When a variable holds a closure or arrow function, the resolution
pipeline resolves it to the Closure class name. The callable
signature (parameter types, return type) is lost. This means:
- Passing
$fnto an extracted method producesClosure $fnwith@param (Closure(): mixed)instead of the concrete signature. - An explicit
/** @var (Closure(int): string) $fn */annotation is recognised by variable resolution (find_var_raw_type_in_sourcereturns the annotated type), butclean_type_for_signaturenow correctly extractsClosureas the native hint. The raw type is preserved for docblock generation.
The remaining gap is that unannotated closures like
$fn = function(int $x): string { ... } resolve to bare Closure
with no signature detail. infer_closure_literal_type (in
rhs_resolution.rs) already embeds the inferred return type in a
TypeKind::Callable, but always leaves params empty, so it does not
produce a full callable type string for variable-type contexts.
Example:
$fn = function(int $x): string { return (string)$x; };
// Extracting code that uses $fn as a parameter produces:
// @param (Closure(): mixed) $fn
// Instead of:
// @param (Closure(int): string) $fn
What needs to change:
-
When resolving a variable whose assignment RHS is a closure or arrow function, build a callable type string from the literal's parameter list and return type hint (e.g.
(Closure(int): string)). Return this as the variable's type string instead of bareClosure. -
clean_type_for_signaturealready handles parenthesized callable types by extracting the base name (Closureorcallable), so the native hint will be correct. -
enrichment_plainshould recognise that a raw type like(Closure(int): string)already carries a full signature and should not be re-enriched to(Closure(): mixed).
After fixing: verify that extract function docblock generation
emits the concrete callable signature in the @param tag.
T20. Type narrowing reconciliation engine¶
Impact: Medium-High · Complexity: Very High
PHPantom's type narrowing in type_engine/types/narrowing/ handles
basic patterns (instanceof, is_* calls, null checks) but lacks the
algebraic framework that PHPStan and Psalm use. Key gaps:
- No separate tracking of "sure types" vs "sure-not types". When
$x !== null, PHPantom should removenullfrom the union (sure-not) rather than trying to intersect with "not-null". - No proper AND/OR algebra.
$a instanceof Foo && $b instanceof Barshould union the narrowings in true context and intersect them in false context. Currently only simple cases work. - No truthy/falsey distinction.
if ($x)(truthy) vsif ($x === true)(strict true) should produce different narrowings. PHPStan uses a 4-state bitmask context.
@phpstan-assert/@psalm-assert annotations on called functions are
now applied as narrowings at call sites (extract_call_assertions and
CallAssertionInfo in type_engine/types/narrowing/assertions.rs,
consulted from type_engine/types/narrowing/guards.rs and
type_engine/variable/forward_walk/cond_narrowing.rs), so that gap
from the original list is closed. It is still ad hoc rather than
going through a unified reconcile dispatch, so items 1-3 remain.
Design: create a
fn reconcile(existing: PhpType, assertion: Assertion, negated: bool) -> PhpType
function that dispatches to per-assertion-kind narrowing logic. Start
with 15 core assertion kinds: IsType, IsNotType, IsNull, IsNotNull,
Truthy, Falsy, IsIdentical, IsNotIdentical, IsInstanceOf,
IsNotInstanceOf, HasMethod, HasProperty, IsGreaterThan, IsLessThan,
NonEmptyCountable.
Reference: Psalm has 41 assertion types under
Psalm/Storage/Assertion/. PHPStan's TypeSpecifier returns
SpecifiedTypes with dual sure/sureNot maps.
Psalm's architecture (reference/psalm):
Psalm converts conditions to Conjunctive Normal Form (CNF). Each
Clause is an OR-disjunction mapping variable string keys to assertion
sets. The Algebra class provides pure functions:
simplifyCNF— unit propagation (($a) ∧ ($a ∨ $b) → $a)getTruthsFromFormula— extract definite truths from unit clausesnegateFormula— De Morgan's law for the else-branch (mechanical, no separate logic needed)combineOredClauses— cartesian product for||
Key design decisions to adopt:
- Clauses are content-addressed (xxh128 hash for dedup). In Rust,
derive
Hash + Eqand useFxHashSet. - Complexity guards — >65K clauses → bail out. Prevents exponential blowup without depth caps.
- Clauses accumulate in context — entering an
ifANDs new clauses with existing ones, giving compound narrowing for free. - Variables identified by string keys (
$a,$this->prop,$a->b[c]) via anExpressionIdentifier— maps to oursubject_extractionapproach. - Assertions are first-class objects with
getNegation()— makes else-branch derivation trivial. - Separate extraction from reconciliation —
AssertionFinder(AST → assertions) is purely separate fromReconciler(assertions + types → narrowed types). Each is independently testable.
See Psalm's Psalm\Internal\Algebra, Psalm\Internal\Clause, and
Psalm\Internal\Analyzer\Statements\Expression\AssertionFinder.
Depends on: The structured type representation (PhpType) has
landed, which makes reconciliation much simpler than working with
raw strings.
T26. Globbed constant unions (Foo::BAR_*)¶
Impact: Low · Complexity: Medium
Resolve wildcard constant patterns 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.
T28. Template inference depth priority (shallowest bound wins)¶
Impact: Medium · Complexity: Medium-High
When a generic type parameter is inferred from multiple argument positions at a call site, all inferred types are currently unioned. This produces overly broad types when a shallow (direct) inference and a deep (nested) inference compete.
Fix: Track an appearance_depth on each inferred template bound.
When the same template param receives bounds from multiple sources,
the shallowest (most direct) match wins. Only union bounds at the
same depth level.
For example, given:
The $value argument gives T = string at depth 0. The $context
argument gives T = int at depth 1 (inside array<>). The shallow
bound (string) should win, not produce string|int.
Design:
struct TemplateBound {
ty: PhpType,
depth: u8, // 0 = direct match, 1 = one generic layer deep, etc.
arg_offset: u8, // which argument produced this bound
}
When selecting the final type for a template param: 1. Group bounds by depth. 2. Take the shallowest depth group. 3. Union the types within that group.
References:
- Psalm: TemplateBound with appearance_depth and
getMostSpecificTypeFromBounds in Psalm\Internal\Type\TemplateResult
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.
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.
References:
- Psalm: Context::$vars_in_scope and Context::$vars_possibly_in_scope
(Psalm\Context)
T30. Literal type collapse limit¶
Impact: Low-Medium · Complexity: Medium
When combining union types, if the number of literal type variants (literal strings, literal ints) exceeds a threshold, collapse them to the parent scalar type. Without this, large switch statements, array initializers, or enum-like constant groups can produce unbounded union types that consume excessive memory and slow down type display.
Fix: In PhpType union combining logic, after merging two types:
if the result contains more than 500 literal string values, replace
them all with string. Same for literal ints → int.
Psalm uses exactly this threshold (500 literals → scalar collapse) in
Type::combineUnionTypes(). The number is chosen to be high enough
that normal code never hits it, but low enough to prevent pathological
blowup.
References:
- Psalm: literal limit in Type::combineUnionTypes() (Psalm\Type)
T31. Closure literal-return shape inference¶
Impact: Low-Medium · Complexity: Medium-High
A closure whose native return hint is just array but whose body
returns a literal array should get the literal shape as its
inferred return type, so array_map() results carry it:
$declarations = array_map(function (ASTNode $child): array {
return [$child->getType(), $childChildren[1]];
}, $children);
[$type, $variable] = $declarations[$index];
$type->getImage(); // "type of '$type' could not be resolved"
Found in the 2026-07 analyze triage: ~10 pdepend errors
(tests/.../PHP82/TrueTypeTest.php:89,
AllowNullAndFalseAsStandAloneTypesTest.php:94,
PHPParserVersion81Test.php:1191,1480) destructure tuples out of
array_map results built this way. PHPStan infers the shape from
the return statements; without it the destructured elements are
unresolved. Depends on nothing else in this file.
References:
- PHPStan: closure return type inference in
ClosureReturnStatementsNode / TypeSpecifyingExtension flow.
- Psalm: ClosureAnalyzer return-type widening.
T33. Class constant on an expression ($obj::CONST) resolves to nothing¶
Impact: Low-Medium · Complexity: Medium
A class constant reached through a variable produces no type at all, so
hover is blank and anything downstream of it loses the value. The same
constant reached through a class name or self resolves fine.
class Foo {
const INTEGER_CONSTANT = 1;
}
$foo = new Foo();
$a = Foo::INTEGER_CONSTANT; // 1
$b = $foo::INTEGER_CONSTANT; // (nothing — PHPStan: 1)
The class-constant branch of resolve_rhs_property_access matches only
Identifier, Self_, Static, and Parent for the left-hand side and
returns an empty vec for anything else. Resolving the subject expression
to a class first (the forward walker already does this for $obj->prop
in the same function) would cover $obj::CONST, and the constant lookup
below it needs no change.
Tests to update once fixed: upstream's nsrt/deducted-types.php has
a $foo::INTEGER_CONSTANT block that was dropped when
tests/phpstan_nsrt/deducted-types.php was ported; port it back.
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: the class-constant branch of
resolve_rhs_property_access, which currently maps Expression::Static
to the current class name identically to Expression::Self_.
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.
T40. pathinfo() returns a shape or a string depending on the flags argument¶
Impact: Low-Medium · Complexity: Medium
$parts = pathinfo($path); // array{dirname: …, basename: …, extension?: …, filename: …}
$name = pathinfo($path, PATHINFO_FILENAME); // string
needsString($name); // reported: got string|array{…} — a single component is always a string
pathinfo() hands back an array of every component when its $flags
argument is left at the default PATHINFO_ALL, and a plain string when
the caller names one component. phpstorm-stubs declare the flat union, so
both call shapes carry the branch the other one takes.
The mechanism this needs already exists, in the shape used for
json_encode's JSON_THROW_ON_ERROR
(type_engine/types/flag_returns.rs): read the flags argument's text at
the call site and pick a branch from it. A conditional return type in
stub_patches.rs cannot express this one, because the deciding value
arrives as a global constant (PATHINFO_FILENAME) and a condition can
only name a literal value or a class constant.
Fix: add pathinfo to flag_returns.rs: the string branch when the
flags argument names one of PATHINFO_DIRNAME, PATHINFO_BASENAME,
PATHINFO_EXTENSION or PATHINFO_FILENAME (or an integer with a single
bit set), and the array branch when the argument is left out. Anything
else keeps the declared union. Left over from the work that took the
preg_replace/str_replace family and json_encode, the two shapes
that accounted for the bulk of the volume.
T41. @param-out is parsed but never read¶
Impact: Medium · Complexity: Medium
/**
* @param-out list<string> $lines
*/
function readInto(string $path, ?array &$lines = null): int { … }
readInto($path, $lines);
$lines[0]; // list<string> is what the tag promises; the declared
// `?array` is what the caller gets
TagKind::ParamOut exists in docblock/tag_kind.rs and nothing
consumes it, so the only thing describing an argument after a call is
the parameter's declared type. That type describes what goes in: a
function that takes array &$buffer and fills it with Token objects
has no way to say so, and a ?array &$out = null says null where the
callee guarantees an array.
The null half of that is handled by ParameterInfo::out_type(), which
drops the null a by-reference parameter's null default implies. That
is a heuristic standing in for the tag: it is right for the standard
library's out-parameters and for the ordinary &$out = null idiom, but
it cannot be argued with by a callee that genuinely may leave the
argument null, and it says nothing about the element types the callee
writes.
Fix: record the tag on ParameterInfo alongside
closure_this_type, and have out_type() prefer it over both the
declared type and the null-default heuristic. Then the two write-back
paths (seed_pass_by_ref_primitives and
try_apply_pass_by_reference_type) pick it up with no further change.
Once it is read, the pcre stub patches can declare preg_match's and
preg_match_all's out types directly rather than inheriting
phpstorm-stubs' null|string[].