PHPantom version
phpantom_lsp 0.8.0 (also reproduces on main)
Installation method
Pre-built binary from GitHub Releases
Operating system
Linux x86_64
Editor
Neovim
Bug description
A named class/interface/trait/enum declared inside a conditional block (an if/else body, or try, switch, etc.) is discovered by name but is never turned into a full ClassInfo — its extends/implements parent and its @extends/@template-extends generics are dropped. As a result the class behaves as if it had no parent: the inheritance chain is broken, generic type parameters don't propagate, completion/hover on inherited members is missing, and find-references on methods called on instances of such a class falls back to over-broad, name-based matching.
The exact real-world trigger is Doctrine's doctrine/doctrine-bundle, which declares ServiceEntityRepository differently per ORM major version, inside a runtime guard:
// vendor/doctrine/doctrine-bundle/src/Repository/ServiceEntityRepository.php (simplified)
if (! property_exists(EntityRepository::class, '_entityName')) {
/** @template T of object @template-extends ServiceEntityRepositoryProxy<T> */
class ServiceEntityRepository extends ServiceEntityRepositoryProxy { /* ... */ }
} else {
/** @template T of object @template-extends LazyServiceEntityRepository<T> */
class ServiceEntityRepository extends LazyServiceEntityRepository { /* ... */ }
}
Because ServiceEntityRepository is declared inside the if, phpantom never records its parent or its <T> binding. The whole chain MyRepository → ServiceEntityRepository<T> → EntityRepository<T> collapses, so $repo->find() resolves to object|null / mixed instead of MyEntity|null. Everything downstream (completion, hover, references on the result) is then untyped.
Expected: a class declared inside an if/else block is indexed with its parent and generics, exactly as a top-level declaration would be.
Actual: only the class name is indexed; parent class and @extends generics are silently dropped, breaking inheritance and generic resolution.
Steps to reproduce
Single self-contained file — no vendor/, no composer.json needed.
<?php
/** @template T of object */
class Repo
{
/** @return T */
public function get(): object {}
}
class Entity
{
public function name(): string {}
}
// --- Control: identical class at TOP LEVEL resolves correctly ---
/** @extends Repo<Entity> */
class TopLevelRepo extends Repo {}
// --- Bug: identical class inside an `if` block does NOT resolve ---
if (\PHP_VERSION_ID >= 80000) {
/** @extends Repo<Entity> */
class ConditionalRepo extends Repo {}
}
function test(TopLevelRepo $a, ConditionalRepo $b): void
{
$x = $a->get(); // OK resolves to `Entity` (top-level class)
$x->name(); // OK `name()` is offered/resolved
$y = $b->get(); // ERR resolves to `object` (unresolved) — should be `Entity`
$y->name(); // ERR `name()` not offered; parent `Repo` is unknown for ConditionalRepo
}
- Open the file in your editor.
- Hover on
$x (from TopLevelRepo) → Entity. Hover on $y (from ConditionalRepo) → object.
- Completion on
$x-> offers name(); completion on $y-> does not.
- Even without generics, go-to-definition / hover does not see
ConditionalRepo's parent
Repo at all — the conditional class has no parent recorded.
The bound (T of object vs T), the kind of guard (PHP_VERSION_ID, function_exists, property_exists, plain if (true)), and inheritance depth do not matter — any named declaration nested in a conditional is affected.
Error output or panic trace
No crash and no error output — purely missing indexing / incorrect type inference.
.phpantom.toml
Additional context
Fix PR: #154
Root cause (traced in source). The class name is found regardless of nesting by the
byte-level scanner (src/classmap_scanner.rs::find_symbols pushes class/interface/
trait/enum names with no brace-depth gate), so the FQN→path classmap is correct. But the
two AST builders that produce the ClassInfo used for type resolution only look at top-level
and namespace-level statements:
src/parser/mod.rs::parse_php_versioned_with_namespaces — the top-level match handles
Statement::Namespace and Statement::Class | Interface | Trait | Enum, but _ => {}
silently drops Statement::If (and every other container).
src/parser/classes.rs::extract_classes_from_statements — its _ arm only calls
find_anonymous_classes_in_statement, which picks up new class {} expressions but not
named class X {} declarations nested in an if/try/switch/block body.
So a conditionally-declared class reaches the index with a name but no parent_class and no
extends_generics, which is what breaks the inheritance/generic chain.
Prior art — how the wider PHP-tooling community handles this:
- PHPStan (
phpstan-src) discovers classes with a fully recursive node visitor
(CachingVisitor driven by a default NodeTraverser) — it descends into if/else
bodies; there is no top-level-only restriction. On duplicate FQN it keeps the first
declaration (current($classNodes[$name])).
- Psalm (
vimeo/psalm) scans with a recursive ReflectorVisitor; it descends into
conditional bodies and keeps the first declaration (emitting DuplicateClass for later
ones). It can statically prune dead branches for class_exists/PHP_VERSION_ID guards,
but does not special-case property_exists — it simply indexes the class anyway.
- Mago (
carthage-software/mago, also Rust) uses a generated recursive MutWalker that
registers class-likes at any nesting depth, including inside if/else.
All three resolve the Doctrine case purely by descending into the conditional and indexing
the class — none of them evaluate the property_exists guard. The common denominator is:
discovery must be nesting-agnostic.
Proposed fix:
- Make both builders descend into the bodies of container statements (
If — both
IfBody::Statement and IfBody::ColonDelimited, plus Try, Block, Switch, loops)
and recurse via extract_classes_from_statements to extract named classes, not only
anonymous ones. This is the core fix and matches PHPStan/Psalm/Mago.
- For the same-FQN-in-two-branches case, keep the first declaration in source order
(de-duplicate by FQN at extraction time). This matches PHPStan/Psalm and phpantom's own
existing first-occurrence-wins convention (classmap_scanner, find_class_by_name's
.find(), fqn_uri_index's entry().or_insert). For the Doctrine shape the first
(textual) branch is the ORM3 ServiceEntityRepositoryProxy arm. No member union, no guard
evaluation, no DuplicateClass-style hard error (which is exactly what breaks users here).
Once the conditional class is a real ClassInfo with its @extends, phpantom's existing
generic-resolution machinery handles the rest.
PHPantom version
phpantom_lsp 0.8.0 (also reproduces on main)
Installation method
Pre-built binary from GitHub Releases
Operating system
Linux x86_64
Editor
Neovim
Bug description
A named class/interface/trait/enum declared inside a conditional block (an
if/elsebody, ortry,switch, etc.) is discovered by name but is never turned into a fullClassInfo— itsextends/implementsparent and its@extends/@template-extendsgenerics are dropped. As a result the class behaves as if it had no parent: the inheritance chain is broken, generic type parameters don't propagate, completion/hover on inherited members is missing, and find-references on methods called on instances of such a class falls back to over-broad, name-based matching.The exact real-world trigger is Doctrine's
doctrine/doctrine-bundle, which declaresServiceEntityRepositorydifferently per ORM major version, inside a runtime guard:Because
ServiceEntityRepositoryis declared inside theif, phpantom never records its parent or its<T>binding. The whole chainMyRepository → ServiceEntityRepository<T> → EntityRepository<T>collapses, so$repo->find()resolves toobject|null/mixedinstead ofMyEntity|null. Everything downstream (completion, hover, references on the result) is then untyped.Expected: a class declared inside an
if/elseblock is indexed with its parent and generics, exactly as a top-level declaration would be.Actual: only the class name is indexed; parent class and
@extendsgenerics are silently dropped, breaking inheritance and generic resolution.Steps to reproduce
Single self-contained file — no
vendor/, nocomposer.jsonneeded.$x(fromTopLevelRepo) →Entity. Hover on$y(fromConditionalRepo) →object.$x->offersname(); completion on$y->does not.ConditionalRepo's parentRepoat all — the conditional class has no parent recorded.The bound (
T of objectvsT), the kind of guard (PHP_VERSION_ID,function_exists,property_exists, plainif (true)), and inheritance depth do not matter — any named declaration nested in a conditional is affected.Error output or panic trace
.phpantom.toml
default / no config fileAdditional context
Fix PR: #154
Root cause (traced in source). The class name is found regardless of nesting by the
byte-level scanner (
src/classmap_scanner.rs::find_symbolspushesclass/interface/trait/enumnames with no brace-depth gate), so the FQN→path classmap is correct. But thetwo AST builders that produce the
ClassInfoused for type resolution only look at top-leveland namespace-level statements:
src/parser/mod.rs::parse_php_versioned_with_namespaces— the top-levelmatchhandlesStatement::NamespaceandStatement::Class | Interface | Trait | Enum, but_ => {}silently drops
Statement::If(and every other container).src/parser/classes.rs::extract_classes_from_statements— its_arm only callsfind_anonymous_classes_in_statement, which picks upnew class {}expressions but notnamed
class X {}declarations nested in anif/try/switch/block body.So a conditionally-declared class reaches the index with a name but no
parent_classand noextends_generics, which is what breaks the inheritance/generic chain.Prior art — how the wider PHP-tooling community handles this:
phpstan-src) discovers classes with a fully recursive node visitor(
CachingVisitordriven by a defaultNodeTraverser) — it descends intoif/elsebodies; there is no top-level-only restriction. On duplicate FQN it keeps the first
declaration (
current($classNodes[$name])).vimeo/psalm) scans with a recursiveReflectorVisitor; it descends intoconditional bodies and keeps the first declaration (emitting
DuplicateClassfor laterones). It can statically prune dead branches for
class_exists/PHP_VERSION_IDguards,but does not special-case
property_exists— it simply indexes the class anyway.carthage-software/mago, also Rust) uses a generated recursiveMutWalkerthatregisters class-likes at any nesting depth, including inside
if/else.All three resolve the Doctrine case purely by descending into the conditional and indexing
the class — none of them evaluate the
property_existsguard. The common denominator is:discovery must be nesting-agnostic.
Proposed fix:
If— bothIfBody::StatementandIfBody::ColonDelimited, plusTry,Block,Switch, loops)and recurse via
extract_classes_from_statementsto extract named classes, not onlyanonymous ones. This is the core fix and matches PHPStan/Psalm/Mago.
(de-duplicate by FQN at extraction time). This matches PHPStan/Psalm and phpantom's own
existing first-occurrence-wins convention (
classmap_scanner,find_class_by_name's.find(),fqn_uri_index'sentry().or_insert). For the Doctrine shape the first(textual) branch is the ORM3
ServiceEntityRepositoryProxyarm. No member union, no guardevaluation, no
DuplicateClass-style hard error (which is exactly what breaks users here).Once the conditional class is a real
ClassInfowith its@extends, phpantom's existinggeneric-resolution machinery handles the rest.