Skip to content

Detect trait and parent class reference cycles instead of walking them forever - #6465

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-ym2k04m
Sep 17, 2026
Merged

ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-ym2k04m

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

A trait using itself (trait T { use T; }) or a group of traits using each other made PHPStan loop until it hit the memory limit. The same happened for a class hierarchy cycle spanning more than one class (class A extends B {}, class B extends A {}).

All of these are fatal errors at runtime in PHP, so the analysis now ends with the Reflection error: Circular reference to class "..." error PHPStan already reported for class A extends A {} instead of hanging.

Changes

  • src/Reflection/ClassReflection.php
    • collectTraits() collects traits keyed by name instead of comparing reflection objects by identity.
    • getAncestors() delegates to a new private collectAncestors() that descends with the collected ancestors as its visited set, instead of recursing into $ancestor->getAncestors(). The resulting order is unchanged.
    • getParentClass() walks the parent chain looking for a cycle and throws CircularReference when it finds one.
    • getTraits(true) and getClassHierarchyDistances() walk the parents via getParentClass() instead of the native reflection, so they are covered by that check.
  • src/Analyser/StmtHandler/TraitUseHandler.php keeps a stack of the traits whose statements are currently being processed and skips a use of any of them, replacing the previous parent-scope walk.
  • build/phpstan.neon excludes the new test case from self-analysis, next to the other test files whose fixtures cannot be reflected (UnionTypesTest.php, MixedTypeTest.php).

Analogous constructs probed that already behaved correctly and needed no change: @mixin cycles, interface A extends B / interface B extends A, trait cycles combined with insteadof and as adaptations, enums using a cyclic trait, trait cycles spanning several files, and an anonymous class using the trait it is declared in (bug 7214).

Root cause

The pattern is cycle detection missing on every walk over the class graph. Four separate walks assumed the graph is a DAG:

  1. ClassReflection::collectTraits() had a visited check, but compared ReflectionClass objects with in_array(..., true). BetterReflection creates a fresh adapter object per getTraits() call, so the check never matched.
  2. ClassReflection::getAncestors() recursed into $interface->getAncestors() / $trait->getAncestors(). Each of those starts with an empty result, so two traits using each other call back and forth forever.
  3. TraitUseHandler::processTraitUse() derived its skip set from Scope::getParentScope(). MutatingScope::enterTrait() leaves the parent scope null, so the set was always empty once the analyser had descended into a trait.
  4. BetterReflection's ReflectionClass::getParentClass() only rejects $this->name === $parentClassName, i.e. a class extending itself. A two-class cycle passes that check, so getTraits(true), getClassHierarchyDistances(), getParentClassesNames() and DependencyResolver::buildClassDependencies() all recursed forever. Putting the check into ClassReflection::getParentClass() fixes all of them at once, because every one of those walks goes through it.

Test

tests/PHPStan/Analyser/AnalyserIntegrationTest (end to end, each hangs until killed without the fix):

  • testBug8082 — the reproducer from the issue: a trait using itself, used by a class.
  • testBug8082TraitCycle — TraitA uses TraitB, TraitB uses TraitA, a class uses TraitA.
  • testCircularParentClass — Foo extends Bar, Bar extends Foo.

tests/PHPStan/Reflection/CircularReferenceClassReflectionTest covers the reflection APIs directly, since that is where the walks live — getTraits(true), getAncestors() and getClassHierarchyDistances() for a self-using trait, a trait cycle and a parent class cycle. Every one of them exhausts the memory limit without the fix.

Fixes phpstan/phpstan#8082

…m forever

- `ClassReflection::collectTraits()` compared already collected traits with
  `in_array($subTrait, $traits, true)`. BetterReflection hands out a fresh
  reflection object on every `getTraits()` call, so a trait reachable from
  itself was never recognised as already seen and the worklist grew forever.
  Collect the traits keyed by name instead.
- `TraitUseHandler` derived the "already inside this trait" set from
  `Scope::getParentScope()`, which only ever sees a trait that is an enclosing
  scope (the anonymous-class case of bug 7214) because `enterTrait()` does not
  set a parent scope. Keep a stack of the traits whose statements are currently
  being processed and skip a `use` of any of them.
- `ClassReflection::getAncestors()` recursed through `$trait->getAncestors()`,
  which never terminates for two traits using each other. It now descends
  through a private helper that uses the collected ancestors as the visited set,
  keeping the previous ordering.
- BetterReflection only rejects a class extending itself directly, so a cycle
  spanning several classes (`A extends B`, `B extends A`) let every walk over
  the class hierarchy run forever. `ClassReflection::getParentClass()` now looks
  for such a cycle and throws `CircularReference`, the same error PHPStan
  already reported for `class A extends A`.
- `ClassReflection::getTraits(true)` and `getClassHierarchyDistances()` walked
  the parents through the native reflection, bypassing that check - they go
  through `getParentClass()` now.

Probed and found already correct: `@mixin` cycles, interface `extends` cycles,
trait cycles with `insteadof`/`as` adaptations, enums using a cyclic trait,
trait cycles spanning several files, and anonymous classes using the trait they
are declared in.
@ondrejmirtes
ondrejmirtes merged commit c0be391 into phpstan:2.2.x Sep 17, 2026
859 of 890 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-ym2k04m branch September 17, 2026 09:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants