Skip to content

Account for subclasses in get_parent_class() of a $this/static value#6048

Merged
staabm merged 11 commits into
phpstan:2.2.xfrom
zonuexe:fix/get-parent-class-non-final-this
Jul 14, 2026
Merged

Account for subclasses in get_parent_class() of a $this/static value#6048
staabm merged 11 commits into
phpstan:2.2.xfrom
zonuexe:fix/get-parent-class-non-final-this

Conversation

@zonuexe

@zonuexe zonuexe commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Closes phpstan/phpstan#14951

get_parent_class($this) inside a non-final class returned the declared parent (or false when there was none), treating $this as exactly the current class.

Through late static binding, $this can be an instance of a subclass, so for a direct child the parent is the class itself, and for a deeper descendant it is a subclass. The return type now also includes class-string<Class> in that case — which removes a false-positive identical.alwaysFalse on get_parent_class($this) === self::class (the reported bug).

Final classes and non-$this/static arguments (typed parameters, new X()) keep the exact parent type, so get_parent_class($typedParam) and get_parent_class(new B()) are unchanged.

Behaviour

Inside a non-final class A (no parent), with class B extends A:

  • get_parent_class($this) in Aclass-string<A>|false (was false).
  • get_parent_class($this) in B'A'|class-string<B> (was 'A').
  • get_parent_class($this) in a final class → unchanged (exact parent / false).
  • get_parent_class($typedParam), get_parent_class(new B()), get_parent_class(SomeClass::class) → unchanged.

The two updated assertions in get-parent-class.php reflect this corrected behaviour.

Note

While checking whether phpstan/phpstan#14951 would interfere with the self/parent/static scope and member-visibility work in #4081 (Closure::bind()), it turned out the two are independent and this one could be fixed on its own — so it is submitted here as a separate PR.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs a rule test showing the rule error in the origin reproducer no longer occurs

@zonuexe zonuexe force-pushed the fix/get-parent-class-non-final-this branch from c4258ce to d814e38 Compare July 14, 2026 05:42
Comment thread src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php Outdated
Comment thread src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php Outdated
@staabm

staabm commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

do we have similar problems with other functions, e.g. get_called_class?

Comment thread src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php
@zonuexe

zonuexe commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Added tests asserting get_called_class()/get_class() keep the subclass possibility (class-string<static(...)>), so they don't share this issue — it was specific to get_parent_class() pinning the parent to the argument's static class.

I also looked at unifying the three: get_class() already goes through Type::toGetClassResultType(), but get_parent_class() intentionally keeps typed params precise (exact parent, see bug-4302b) whereas get_class() widens them to class-string<Sub>, so a shared abstraction would either regress that precision or add indirection without removing real duplication. Kept them separate.

Comment thread tests/PHPStan/Rules/Comparison/data/bug-14951.php
Comment on lines +52 to +53
assertType('\'ParentClass\\\\Foo\'', get_parent_class(self::class));
assertType('\'ParentClass\\\\Foo\'|class-string<ParentClass\Bar>', get_parent_class(static::class));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure it makes sense that inside the trait we assume we know the class/all classes this trait might be used by. @VincentLanglet wdyt?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the classic behavior of the trait, everything is computed in the context of the class which use the trait. I'm not sure it's easy to change

@staabm staabm Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, was not sure tbh. I though this is true from within the class using the trait, but not the trait itself. I might missremember.

in case you think it is fine, we move ahead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're right, looking at
https://phpstan.org/r/e81f1d9e-19da-42b4-b576-d52cd54be556

We should have a similar behavior than

assertType('class-string|false', get_parent_class($this));

(which already work for get_parent_class(static::class))

@staabm staabm requested a review from VincentLanglet July 14, 2026 08:12

@VincentLanglet VincentLanglet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be class-string|false in a trait.

Comment on lines +52 to +53
assertType('\'ParentClass\\\\Foo\'', get_parent_class(self::class));
assertType('\'ParentClass\\\\Foo\'|class-string<ParentClass\Bar>', get_parent_class(static::class));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're right, looking at
https://phpstan.org/r/e81f1d9e-19da-42b4-b576-d52cd54be556

We should have a similar behavior than

assertType('class-string|false', get_parent_class($this));

(which already work for get_parent_class(static::class))

assertType('class-string|false', get_parent_class());
assertType('class-string|false', get_parent_class($this));
// self::class / static::class resolve against the using class (Bar).
assertType('\'ParentClass\\\\Foo\'', get_parent_class(self::class));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about self::class ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self::class isn't late static bound — inside the trait it's already the using class as a constant class-string ('ParentClass\Bar'), which is indistinguishable from writing Bar::class. So the extension sees the very same ConstantString in both cases and there's nothing to be conservative about; making it class-string|false would mean special-casing the syntax and would contradict get_parent_class(Bar::class) returning 'ParentClass\Foo'.

The guard applies to $this / static::class because those carry the subclass uncertainty. I added assertions covering all three (plus Bar::class side by side) to make this explicit.

@VincentLanglet VincentLanglet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't weird having a different behavior in trait for

get_parent_class(static::class);

and

get_parent_class(self::class)

@zonuexe @staabm ?

I would expect class-string|false

@zonuexe

zonuexe commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

I don't think it's the same kind of thing. self::class is not late static bound: inside the trait it is already resolved to the using class as a plain constant ('ParentClass\Bar'), exactly like new self(), self::C and parent::class are.

Two consequences:

  • A guard on self::class would be purely syntactic and trivially bypassed:
    $c = self::class;               // 'ParentClass\Bar'
    get_parent_class($c);           // 'ParentClass\Foo' — nothing to guard against
  • parent::class inside the trait already gives 'ParentClass\Foo', so making get_parent_class(self::class) return class-string|false would hide nothing.

Making it conservative would mean not resolving self::class against the using class at all, which is the core trait behavior you mentioned isn't easy to change.

$this / static::class are different: they carry the subclass uncertainty (the runtime class can be a subclass of the using class), which is why they stay class-string|false — and that mirrors what happens outside a trait, where self::class is exact while static::class widens to class-string<A>|false.

@VincentLanglet VincentLanglet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could add a test with @final phpdoc ? Which should be considered as final too.

zonuexe added 11 commits July 14, 2026 13:55
get_parent_class($this) inside a non-final class returned the declared parent (or
false when there was none), treating $this as exactly the current class. Through
late static binding $this can be an instance of a subclass, so for a direct child
the parent is the class itself and for a deeper descendant it is a subclass. The
return type now also includes class-string<Class> in that case, which removes a
false-positive identical.alwaysFalse on get_parent_class($this) === self::class.

Final classes and non-$this/static arguments (typed parameters, new X()) keep the
exact parent type.

Closes phpstan/phpstan#14951
…lways false

Covers the reported identical.alwaysFalse false positive at the rule level, in
addition to the type-inference assertions.
…union

Addresses review: avoid calling TypeCombinator::union() inside the loop and use a
plain foreach for readability.
They already return class-string<static(...)>/class-string<$this(...)>, so they keep
the subclass possibility instead of pinning to the current class.
A class-string argument names an exact class, so it is not subject to the subclass
widening: get_parent_class(self::class) stays false / the exact parent.
static::class names the late-static-bound class, so it carries the same subclass
possibility as $this. Unwrap the class-string<static(...)> and reuse the object
handling, giving class-string<Class>|<parent> instead of the wide class-string|false.
self::class / X::class name an exact class and keep the exact parent.
…t method

$this stays deferred to the general type via the existing in-trait guard; self::class
and static::class resolve against the using class the same way as outside a trait.
… a trait

The in-trait guard already bails out for $this. Apply it to static::class as well, so
a trait does not resolve a late-static-bound argument against one particular using
class. Outside a trait static::class keeps the narrowed type.
self::class is not late static bound: inside a trait it is already the using class as a
constant class-string, indistinguishable from writing Bar::class, so it keeps the exact
parent while $this / static::class stay conservative.
ClassReflection::isFinal() already honours the @Final PHPDoc, so an @Final class gets
no subclass widening, same as the final keyword.
getClassStringObjectType() returns an ErrorType for non-class-string types, so the
isClassString()->yes() guard was redundant. Dropping it also removes an escaped
TrinaryLogic mutant reported by mutation testing.
@staabm staabm force-pushed the fix/get-parent-class-non-final-this branch from 7393c1e to 035e89f Compare July 14, 2026 11:55
@staabm staabm merged commit 01262e4 into phpstan:2.2.x Jul 14, 2026
679 of 684 checks passed
@staabm

staabm commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

thank you @zonuexe

@zonuexe zonuexe deleted the fix/get-parent-class-non-final-this branch July 14, 2026 12:26
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.

PHPStan reports identical.alwaysFalse on get_parent_class($this) === self::class inside a non-final class

3 participants