From 050504e0c92d4ebb7e7eb4de20e1b2c1cf03db13 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 14 Jul 2026 12:12:29 +0900 Subject: [PATCH 01/11] Account for subclasses in get_parent_class() of a $this/static value 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 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 --- ...lassDynamicFunctionReturnTypeExtension.php | 22 +++++++- tests/PHPStan/Analyser/nsrt/bug-14951.php | 50 +++++++++++++++++++ .../Analyser/nsrt/get-parent-class.php | 4 +- 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14951.php diff --git a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php index e4db21dda94..a49c0f6e66e 100644 --- a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php +++ b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php @@ -12,6 +12,9 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; +use PHPStan\Type\Generic\GenericClassStringType; +use PHPStan\Type\ObjectType; +use PHPStan\Type\StaticType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; @@ -65,7 +68,24 @@ public function getTypeFromFunctionCall( $classNames = $argType->getObjectClassNames(); if (count($classNames) > 0) { - return TypeCombinator::union(...array_map(fn (string $classNames): Type => $this->findParentClassNameType($classNames), $classNames)); + // A `$this`/`static` value can be an instance of a subclass through late static + // binding. For a non-final class the parent class is then not pinned to the declared + // parent: a direct child's parent is the class itself, a deeper descendant's parent is + // some subclass. So the result also includes `class-string`. + $isLateStaticBound = $argType instanceof StaticType; + + return TypeCombinator::union(...array_map(function (string $className) use ($isLateStaticBound): Type { + $parentType = $this->findParentClassNameType($className); + if (!$isLateStaticBound || !$this->reflectionProvider->hasClass($className)) { + return $parentType; + } + + if ($this->reflectionProvider->getClass($className)->isFinal()) { + return $parentType; + } + + return TypeCombinator::union($parentType, new GenericClassStringType(new ObjectType($className))); + }, $classNames)); } return null; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14951.php b/tests/PHPStan/Analyser/nsrt/bug-14951.php new file mode 100644 index 00000000000..8cb170e428b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14951.php @@ -0,0 +1,50 @@ +|false', get_parent_class($this)); + + return get_parent_class($this) === self::class; + } + +} + +class B extends A +{ + + public function parentOfThis(): void + { + assertType('\'Bug14951\\\\A\'|class-string', get_parent_class($this)); + } + +} + +final class FinalNoParent +{ + + public function parentOfThis(): void + { + // Final class cannot be subclassed, so the parent is exactly false. + assertType('false', get_parent_class($this)); + } + +} + +final class FinalWithParent extends A +{ + + public function parentOfThis(): void + { + assertType("'Bug14951\\\\A'", get_parent_class($this)); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/get-parent-class.php b/tests/PHPStan/Analyser/nsrt/get-parent-class.php index 5e6c291e6ef..4a3a80a1c34 100644 --- a/tests/PHPStan/Analyser/nsrt/get-parent-class.php +++ b/tests/PHPStan/Analyser/nsrt/get-parent-class.php @@ -10,7 +10,7 @@ class Foo public function doFoo() { assertType('false', get_parent_class()); - assertType('false', get_parent_class($this)); + assertType('class-string|false', get_parent_class($this)); assertType('class-string<$this(ParentClass\Foo)>', get_class($this)); assertType('\'ParentClass\\\\Foo\'', get_class()); } @@ -25,7 +25,7 @@ class Bar extends Foo public function doBar() { assertType('\'ParentClass\\\\Foo\'', get_parent_class()); - assertType('\'ParentClass\\\\Foo\'', get_parent_class($this)); + assertType('\'ParentClass\\\\Foo\'|class-string', get_parent_class($this)); } } From 15e73fcdbed8a10bc1f09de153ac6aadd547600e Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 14 Jul 2026 14:38:57 +0900 Subject: [PATCH 02/11] Add rule test for get_parent_class($this) === self::class not being always false Covers the reported identical.alwaysFalse false positive at the rule level, in addition to the type-inference assertions. --- ...rictComparisonOfDifferentTypesRuleTest.php | 5 ++++ .../Rules/Comparison/data/bug-14951.php | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14951.php diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index f8f38214d8c..0d476571c8a 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -347,6 +347,11 @@ public function testBug2835(): void $this->analyse([__DIR__ . '/data/bug-2835.php'], []); } + public function testBug14951(): void + { + $this->analyse([__DIR__ . '/data/bug-14951.php'], []); + } + public function testBug1860(): void { $this->analyse([__DIR__ . '/data/bug-1860.php'], [ diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14951.php b/tests/PHPStan/Rules/Comparison/data/bug-14951.php new file mode 100644 index 00000000000..6d065f17c6f --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14951.php @@ -0,0 +1,23 @@ + Date: Tue, 14 Jul 2026 16:35:15 +0900 Subject: [PATCH 03/11] Build the get_parent_class() return type with a foreach and a single union Addresses review: avoid calling TypeCombinator::union() inside the loop and use a plain foreach for readability. --- ...lassDynamicFunctionReturnTypeExtension.php | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php index a49c0f6e66e..4e54a056043 100644 --- a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php +++ b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php @@ -74,18 +74,22 @@ public function getTypeFromFunctionCall( // some subclass. So the result also includes `class-string`. $isLateStaticBound = $argType instanceof StaticType; - return TypeCombinator::union(...array_map(function (string $className) use ($isLateStaticBound): Type { - $parentType = $this->findParentClassNameType($className); - if (!$isLateStaticBound || !$this->reflectionProvider->hasClass($className)) { - return $parentType; + $types = []; + foreach ($classNames as $className) { + $types[] = $this->findParentClassNameType($className); + + if ( + !$isLateStaticBound + || !$this->reflectionProvider->hasClass($className) + || $this->reflectionProvider->getClass($className)->isFinal() + ) { + continue; } - if ($this->reflectionProvider->getClass($className)->isFinal()) { - return $parentType; - } + $types[] = new GenericClassStringType(new ObjectType($className)); + } - return TypeCombinator::union($parentType, new GenericClassStringType(new ObjectType($className))); - }, $classNames)); + return TypeCombinator::union(...$types); } return null; From 2903e1efaf9f8d4b58220fa07c8e9e7391dbf3cb Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 14 Jul 2026 16:46:34 +0900 Subject: [PATCH 04/11] Assert get_called_class()/get_class() are unaffected by the same problem They already return class-string/class-string<$this(...)>, so they keep the subclass possibility instead of pinning to the current class. --- tests/PHPStan/Analyser/nsrt/bug-14951.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14951.php b/tests/PHPStan/Analyser/nsrt/bug-14951.php index 8cb170e428b..9ea33eae8f0 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14951.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14951.php @@ -13,6 +13,11 @@ public function isDirectChildOfA(): bool // may return A::class (for a direct child) — not just false. assertType('class-string|false', get_parent_class($this)); + // The sibling functions already model late static binding, so they do not have the + // same problem: they keep the subclass possibility instead of pinning to this class. + assertType('class-string', get_called_class()); + assertType('class-string<$this(Bug14951\A)>', get_class($this)); + return get_parent_class($this) === self::class; } @@ -35,6 +40,8 @@ public function parentOfThis(): void { // Final class cannot be subclassed, so the parent is exactly false. assertType('false', get_parent_class($this)); + // ... and get_called_class() is pinned to the final class. + assertType("'Bug14951\\\\FinalNoParent'", get_called_class()); } } From 8d0091a4ea7a2ad06b5281eddc416acb2a6ceb00 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 14 Jul 2026 16:52:59 +0900 Subject: [PATCH 05/11] Cover get_parent_class(self::class) keeping the exact parent 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. --- tests/PHPStan/Analyser/nsrt/bug-14951.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14951.php b/tests/PHPStan/Analyser/nsrt/bug-14951.php index 9ea33eae8f0..47414892d1c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14951.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14951.php @@ -18,6 +18,10 @@ public function isDirectChildOfA(): bool assertType('class-string', get_called_class()); assertType('class-string<$this(Bug14951\A)>', get_class($this)); + // A class-string argument (e.g. self::class) names an exact class, not a runtime + // value, so it keeps the exact parent — no subclass widening. + assertType('false', get_parent_class(self::class)); + return get_parent_class($this) === self::class; } @@ -29,6 +33,8 @@ class B extends A public function parentOfThis(): void { assertType('\'Bug14951\\\\A\'|class-string', get_parent_class($this)); + // self::class is the exact class B, so its parent is exactly A. + assertType("'Bug14951\\\\A'", get_parent_class(self::class)); } } From 8f1f3382ec97166eddc941b274de8f5be9bf755c Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 14 Jul 2026 16:58:06 +0900 Subject: [PATCH 06/11] Narrow get_parent_class(static::class) like get_parent_class($this) static::class names the late-static-bound class, so it carries the same subclass possibility as $this. Unwrap the class-string and reuse the object handling, giving class-string| instead of the wide class-string|false. self::class / X::class name an exact class and keep the exact parent. --- ...rentClassDynamicFunctionReturnTypeExtension.php | 14 ++++++++++++-- tests/PHPStan/Analyser/nsrt/bug-14951.php | 6 ++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php index 4e54a056043..5093dfdccb3 100644 --- a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php +++ b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php @@ -66,13 +66,23 @@ public function getTypeFromFunctionCall( return TypeCombinator::union(...array_map(fn (ConstantStringType $stringType): Type => $this->findParentClassNameType($stringType->getValue()), $constantStrings)); } - $classNames = $argType->getObjectClassNames(); + // A `static::class` string refers to the same late-static-bound type as `$this`/`static`, + // so unwrap it and reuse the object handling below. + $valueType = $argType; + if ($argType->isClassString()->yes()) { + $classStringObjectType = $argType->getClassStringObjectType(); + if ($classStringObjectType instanceof StaticType) { + $valueType = $classStringObjectType; + } + } + + $classNames = $valueType->getObjectClassNames(); if (count($classNames) > 0) { // A `$this`/`static` value can be an instance of a subclass through late static // binding. For a non-final class the parent class is then not pinned to the declared // parent: a direct child's parent is the class itself, a deeper descendant's parent is // some subclass. So the result also includes `class-string`. - $isLateStaticBound = $argType instanceof StaticType; + $isLateStaticBound = $valueType instanceof StaticType; $types = []; foreach ($classNames as $className) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14951.php b/tests/PHPStan/Analyser/nsrt/bug-14951.php index 47414892d1c..40902c44f82 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14951.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14951.php @@ -22,6 +22,9 @@ public function isDirectChildOfA(): bool // value, so it keeps the exact parent — no subclass widening. assertType('false', get_parent_class(self::class)); + // static::class is late static bound just like $this, so it is widened the same way. + assertType('class-string|false', get_parent_class(static::class)); + return get_parent_class($this) === self::class; } @@ -35,6 +38,8 @@ public function parentOfThis(): void assertType('\'Bug14951\\\\A\'|class-string', get_parent_class($this)); // self::class is the exact class B, so its parent is exactly A. assertType("'Bug14951\\\\A'", get_parent_class(self::class)); + // static::class is late static bound, matching get_parent_class($this). + assertType('\'Bug14951\\\\A\'|class-string', get_parent_class(static::class)); } } @@ -46,6 +51,7 @@ public function parentOfThis(): void { // Final class cannot be subclassed, so the parent is exactly false. assertType('false', get_parent_class($this)); + assertType('false', get_parent_class(static::class)); // ... and get_called_class() is pinned to the final class. assertType("'Bug14951\\\\FinalNoParent'", get_called_class()); } From b342212dbdbb7c3da1ccb1706a38045e4836b46e Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 14 Jul 2026 17:01:40 +0900 Subject: [PATCH 07/11] Cover get_parent_class() with self::class/static::class inside a trait 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. --- tests/PHPStan/Analyser/nsrt/get-parent-class.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/get-parent-class.php b/tests/PHPStan/Analyser/nsrt/get-parent-class.php index 4a3a80a1c34..0214d5aa13a 100644 --- a/tests/PHPStan/Analyser/nsrt/get-parent-class.php +++ b/tests/PHPStan/Analyser/nsrt/get-parent-class.php @@ -45,8 +45,12 @@ trait FooTrait public function doBaz() { + // Inside a trait the using class is unknown, so $this defers to the general type. 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)); + assertType('\'ParentClass\\\\Foo\'|class-string', get_parent_class(static::class)); } } From 3ac3184490b72674f7e0f0ed599d120c4c4a7e70 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 14 Jul 2026 18:01:33 +0900 Subject: [PATCH 08/11] Do not resolve a late-static-bound get_parent_class() argument inside 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. --- .../GetParentClassDynamicFunctionReturnTypeExtension.php | 6 ++++++ tests/PHPStan/Analyser/nsrt/get-parent-class.php | 6 ++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php index 5093dfdccb3..938d34dd95c 100644 --- a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php +++ b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php @@ -76,6 +76,12 @@ public function getTypeFromFunctionCall( } } + // Inside a trait a late-static-bound value cannot be resolved to a concrete parent, + // same as `$this` above. + if ($scope->isInTrait() && $valueType instanceof StaticType) { + return null; + } + $classNames = $valueType->getObjectClassNames(); if (count($classNames) > 0) { // A `$this`/`static` value can be an instance of a subclass through late static diff --git a/tests/PHPStan/Analyser/nsrt/get-parent-class.php b/tests/PHPStan/Analyser/nsrt/get-parent-class.php index 0214d5aa13a..48bcc4dbb9f 100644 --- a/tests/PHPStan/Analyser/nsrt/get-parent-class.php +++ b/tests/PHPStan/Analyser/nsrt/get-parent-class.php @@ -45,12 +45,10 @@ trait FooTrait public function doBaz() { - // Inside a trait the using class is unknown, so $this defers to the general type. + // Inside a trait a late-static-bound argument is not resolved to a concrete parent. 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)); - assertType('\'ParentClass\\\\Foo\'|class-string', get_parent_class(static::class)); + assertType('class-string|false', get_parent_class(static::class)); } } From fc781b91befde17799037f307904ebe4e9fbd1fb Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 14 Jul 2026 18:09:17 +0900 Subject: [PATCH 09/11] Cover get_parent_class(self::class) inside a trait 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. --- tests/PHPStan/Analyser/nsrt/get-parent-class.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/get-parent-class.php b/tests/PHPStan/Analyser/nsrt/get-parent-class.php index 48bcc4dbb9f..b3caa68b93b 100644 --- a/tests/PHPStan/Analyser/nsrt/get-parent-class.php +++ b/tests/PHPStan/Analyser/nsrt/get-parent-class.php @@ -49,6 +49,12 @@ public function doBaz() assertType('class-string|false', get_parent_class()); assertType('class-string|false', get_parent_class($this)); assertType('class-string|false', get_parent_class(static::class)); + + // self::class is not late static bound - it is the using class, so it is already a + // constant class-string here, indistinguishable from writing Bar::class. + assertType('\'ParentClass\\\\Bar\'', self::class); + assertType('\'ParentClass\\\\Foo\'', get_parent_class(self::class)); + assertType('\'ParentClass\\\\Foo\'', get_parent_class(Bar::class)); } } From 2e4c4afd93f45324a3269c60826c4124799919d0 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 14 Jul 2026 20:05:26 +0900 Subject: [PATCH 10/11] Cover @final classes in get_parent_class() ClassReflection::isFinal() already honours the @final PHPDoc, so an @final class gets no subclass widening, same as the final keyword. --- tests/PHPStan/Analyser/nsrt/bug-14951.php | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14951.php b/tests/PHPStan/Analyser/nsrt/bug-14951.php index 40902c44f82..d7df8f9e4eb 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14951.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14951.php @@ -64,6 +64,32 @@ final class FinalWithParent extends A public function parentOfThis(): void { assertType("'Bug14951\\\\A'", get_parent_class($this)); + assertType("'Bug14951\\\\A'", get_parent_class(static::class)); + } + +} + +/** @final */ +class PhpDocFinalNoParent +{ + + public function parentOfThis(): void + { + // A @final class cannot be subclassed either, so there is no subclass widening. + assertType('false', get_parent_class($this)); + assertType('false', get_parent_class(static::class)); + } + +} + +/** @final */ +class PhpDocFinalWithParent extends A +{ + + public function parentOfThis(): void + { + assertType("'Bug14951\\\\A'", get_parent_class($this)); + assertType("'Bug14951\\\\A'", get_parent_class(static::class)); } } From 035e89f15410b59e6a51d072bbf1103e210f7c55 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 14 Jul 2026 20:35:38 +0900 Subject: [PATCH 11/11] Resolve the class-string object type without a TrinaryLogic check 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. --- ...tParentClassDynamicFunctionReturnTypeExtension.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php index 938d34dd95c..5854d0f778e 100644 --- a/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php +++ b/src/Type/Php/GetParentClassDynamicFunctionReturnTypeExtension.php @@ -67,13 +67,12 @@ public function getTypeFromFunctionCall( } // A `static::class` string refers to the same late-static-bound type as `$this`/`static`, - // so unwrap it and reuse the object handling below. + // so unwrap it and reuse the object handling below. Non-class-string types resolve to + // an ErrorType here, so they are left alone. $valueType = $argType; - if ($argType->isClassString()->yes()) { - $classStringObjectType = $argType->getClassStringObjectType(); - if ($classStringObjectType instanceof StaticType) { - $valueType = $classStringObjectType; - } + $classStringObjectType = $argType->getClassStringObjectType(); + if ($classStringObjectType instanceof StaticType) { + $valueType = $classStringObjectType; } // Inside a trait a late-static-bound value cannot be resolved to a concrete parent,