From 4a7c91181758398df949c634a02573301eb7478f Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:13:01 +0000 Subject: [PATCH 01/11] Use per-class method reflection when resolving dynamic return type extensions on union types - In `MethodCallReturnTypeHelper::methodCallReturnType()`, the per-class-name loop passed the union's combined `MethodReflection` to `isMethodSupported()` and `getTypeFromMethodCall()`/`getTypeFromStaticMethodCall()`. Extensions that check `$methodReflection->getDeclaringClass()->getName()` (e.g. `ReflectionGetAttributesMethodReturnTypeExtension`) only matched the declaring class of `UnionTypeMethodReflection::$methods[0]`, causing extensions for other union members to be skipped. - Now create a per-class `ObjectType` and get the method reflection from it for each iteration, so each extension sees the correct declaring class. - Also applies to the static method call branch in the same loop. --- .../Helper/MethodCallReturnTypeHelper.php | 13 ++-- tests/PHPStan/Analyser/nsrt/bug-14466.php | 68 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14466.php diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index da565b78d8d..e09e377f26d 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -56,13 +56,18 @@ public function methodCallReturnType( $allClassNames = $typeWithMethod->getObjectClassNames(); $handledClassNames = []; foreach ($allClassNames as $className) { + $classType = new ObjectType($className); + if (!$classType->hasMethod($methodName)->yes()) { + continue; + } + $classMethodReflection = $classType->getMethod($methodName, $scope); if ($normalizedMethodCall instanceof MethodCall) { foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { - if (!$dynamicMethodReturnTypeExtension->isMethodSupported($methodReflection)) { + if (!$dynamicMethodReturnTypeExtension->isMethodSupported($classMethodReflection)) { continue; } - $resolvedType = $dynamicMethodReturnTypeExtension->getTypeFromMethodCall($methodReflection, $normalizedMethodCall, $scope); + $resolvedType = $dynamicMethodReturnTypeExtension->getTypeFromMethodCall($classMethodReflection, $normalizedMethodCall, $scope); if ($resolvedType === null) { continue; } @@ -72,12 +77,12 @@ public function methodCallReturnType( } } else { foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { - if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($methodReflection)) { + if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($classMethodReflection)) { continue; } $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( - $methodReflection, + $classMethodReflection, $normalizedMethodCall, $scope, ); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14466.php b/tests/PHPStan/Analyser/nsrt/bug-14466.php new file mode 100644 index 00000000000..33c83ac23b7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14466.php @@ -0,0 +1,68 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug14466; + +use ReflectionAttribute; +use ReflectionClass; +use ReflectionMethod; +use function PHPStan\Testing\assertType; + +interface I +{ + +} + +class Bug +{ + /** + * @param ReflectionClass<*> $object + */ + protected function c(ReflectionClass $object): void + { + $requirements = $object->getAttributes(I::class, ReflectionAttribute::IS_INSTANCEOF); + + assertType('list>', $requirements); + } + + /** + * @param ReflectionMethod $object + */ + protected function m(ReflectionMethod $object): void + { + $requirements = $object->getAttributes(I::class, ReflectionAttribute::IS_INSTANCEOF); + + assertType('list>', $requirements); + } + + /** + * @param ReflectionClass<*>|ReflectionMethod $object + */ + protected function classOrMethod(ReflectionClass|ReflectionMethod $object): void + { + $requirements = $object->getAttributes(I::class, ReflectionAttribute::IS_INSTANCEOF); + + assertType('list>', $requirements); + } + + /** + * @param ReflectionClass<*>|\ReflectionProperty $object + */ + protected function classOrProperty(ReflectionClass|\ReflectionProperty $object): void + { + $requirements = $object->getAttributes(I::class, ReflectionAttribute::IS_INSTANCEOF); + + assertType('list>', $requirements); + } + + /** + * @param ReflectionMethod|\ReflectionProperty $object + */ + protected function methodOrProperty(ReflectionMethod|\ReflectionProperty $object): void + { + $requirements = $object->getAttributes(I::class, ReflectionAttribute::IS_INSTANCEOF); + + assertType('list>', $requirements); + } +} From b95b996e690f6c9037a27a1969e8f6c4c41c406c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 11:50:58 +0000 Subject: [PATCH 02/11] Iterate over union inner types instead of constructing new ObjectType from class names This preserves GenericObjectType and TemplateObjectType information that was lost when creating `new ObjectType($className)` from just the class name string. Co-Authored-By: Claude Opus 4.6 --- .../Helper/MethodCallReturnTypeHelper.php | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index e09e377f26d..89adc291d34 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -12,6 +12,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\UnionType; use function count; #[AutowiredService] @@ -55,43 +56,46 @@ public function methodCallReturnType( $resolvedTypes = []; $allClassNames = $typeWithMethod->getObjectClassNames(); $handledClassNames = []; - foreach ($allClassNames as $className) { - $classType = new ObjectType($className); - if (!$classType->hasMethod($methodName)->yes()) { + $innerTypes = $typeWithMethod instanceof UnionType ? $typeWithMethod->getTypes() : [$typeWithMethod]; + foreach ($innerTypes as $innerType) { + $classNames = $innerType->getObjectClassNames(); + if ($classNames === [] || !$innerType->hasMethod($methodName)->yes()) { continue; } - $classMethodReflection = $classType->getMethod($methodName, $scope); - if ($normalizedMethodCall instanceof MethodCall) { - foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { - if (!$dynamicMethodReturnTypeExtension->isMethodSupported($classMethodReflection)) { - continue; - } + $classMethodReflection = $innerType->getMethod($methodName, $scope); + foreach ($classNames as $className) { + if ($normalizedMethodCall instanceof MethodCall) { + foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { + if (!$dynamicMethodReturnTypeExtension->isMethodSupported($classMethodReflection)) { + continue; + } - $resolvedType = $dynamicMethodReturnTypeExtension->getTypeFromMethodCall($classMethodReflection, $normalizedMethodCall, $scope); - if ($resolvedType === null) { - continue; - } + $resolvedType = $dynamicMethodReturnTypeExtension->getTypeFromMethodCall($classMethodReflection, $normalizedMethodCall, $scope); + if ($resolvedType === null) { + continue; + } - $resolvedTypes[] = $resolvedType; - $handledClassNames[] = $className; - } - } else { - foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { - if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($classMethodReflection)) { - continue; + $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; } + } else { + foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { + if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($classMethodReflection)) { + continue; + } - $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( - $classMethodReflection, - $normalizedMethodCall, - $scope, - ); - if ($resolvedType === null) { - continue; - } + $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( + $classMethodReflection, + $normalizedMethodCall, + $scope, + ); + if ($resolvedType === null) { + continue; + } - $resolvedTypes[] = $resolvedType; - $handledClassNames[] = $className; + $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; + } } } } From b77c951e4a2f312dfd08fbb44ffe04bf9ff60e99 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 12:14:40 +0000 Subject: [PATCH 03/11] Fix declaring class check in ReflectionGetAttributesMethodReturnTypeExtension instead of modifying MethodCallReturnTypeHelper Revert changes to MethodCallReturnTypeHelper and instead remove the $methodReflection->getDeclaringClass()->getName() check from the extension's isMethodSupported(). The getClass() method + extension registry already ensure each extension instance is only invoked for the correct class hierarchy, making the declaring class check redundant. The declaring class check was also the root cause of the union type bug: UnionTypeMethodReflection::getDeclaringClass() returns only the first member's declaring class, so extensions for other union members were not matched. Co-Authored-By: Claude Opus 4.6 --- .../Helper/MethodCallReturnTypeHelper.php | 63 ++++++++----------- src/Reflection/ClassReflection.php | 2 +- ...GetAttributesMethodReturnTypeExtension.php | 3 +- 3 files changed, 29 insertions(+), 39 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index 89adc291d34..da565b78d8d 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -12,7 +12,6 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use PHPStan\Type\UnionType; use function count; #[AutowiredService] @@ -56,46 +55,38 @@ public function methodCallReturnType( $resolvedTypes = []; $allClassNames = $typeWithMethod->getObjectClassNames(); $handledClassNames = []; - $innerTypes = $typeWithMethod instanceof UnionType ? $typeWithMethod->getTypes() : [$typeWithMethod]; - foreach ($innerTypes as $innerType) { - $classNames = $innerType->getObjectClassNames(); - if ($classNames === [] || !$innerType->hasMethod($methodName)->yes()) { - continue; - } - $classMethodReflection = $innerType->getMethod($methodName, $scope); - foreach ($classNames as $className) { - if ($normalizedMethodCall instanceof MethodCall) { - foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { - if (!$dynamicMethodReturnTypeExtension->isMethodSupported($classMethodReflection)) { - continue; - } - - $resolvedType = $dynamicMethodReturnTypeExtension->getTypeFromMethodCall($classMethodReflection, $normalizedMethodCall, $scope); - if ($resolvedType === null) { - continue; - } + foreach ($allClassNames as $className) { + if ($normalizedMethodCall instanceof MethodCall) { + foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { + if (!$dynamicMethodReturnTypeExtension->isMethodSupported($methodReflection)) { + continue; + } - $resolvedTypes[] = $resolvedType; - $handledClassNames[] = $className; + $resolvedType = $dynamicMethodReturnTypeExtension->getTypeFromMethodCall($methodReflection, $normalizedMethodCall, $scope); + if ($resolvedType === null) { + continue; } - } else { - foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { - if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($classMethodReflection)) { - continue; - } - $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( - $classMethodReflection, - $normalizedMethodCall, - $scope, - ); - if ($resolvedType === null) { - continue; - } + $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; + } + } else { + foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { + if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($methodReflection)) { + continue; + } - $resolvedTypes[] = $resolvedType; - $handledClassNames[] = $className; + $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( + $methodReflection, + $normalizedMethodCall, + $scope, + ); + if ($resolvedType === null) { + continue; } + + $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; } } } diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index 3df31e38aff..32bf08f53c9 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -1570,7 +1570,7 @@ private function findAttributeFlags(): ?int $attributeClass = $this->reflectionProvider->getClass(Attribute::class); $arguments = []; - foreach ($nativeAttributes[0]->getArgumentsExpressions() as $i => $expression) { + foreach ($nativeAttributes[0]->getArgumentsExpressions() as $i => $expression) { // @phpstan-ignore method.notFound (method exists on BetterReflection's ReflectionAttribute adapter) if ($i === '') { throw new ShouldNotHappenException(); } diff --git a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php index 1077c912c40..d5a21b7f624 100644 --- a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php +++ b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php @@ -32,8 +32,7 @@ public function getClass(): string public function isMethodSupported(MethodReflection $methodReflection): bool { - return $methodReflection->getDeclaringClass()->getName() === $this->className - && $methodReflection->getName() === 'getAttributes'; + return $methodReflection->getName() === 'getAttributes'; } public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type From 68abeebfdabf09d2c017cebd15af0a72196d1de0 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 13:17:15 +0000 Subject: [PATCH 04/11] Skip extension for non-native reflection classes instead of removing declaring class check entirely The previous approach (removing the declaring class check entirely) caused the extension to fire during self-analysis where BetterReflection adapters override getAttributes(). This overrode BetterReflection's accurate return type with the native ReflectionAttribute type, which lacks getArgumentsExpressions(). Instead of checking declaring class name === className (which fails for union types because UnionTypeMethodReflection returns only the first member's declaring class), check that the declaring class is a native PHP class (no namespace separator). This correctly: - Fires for user code (declaring class is native ReflectionClass etc.) - Fires for union types (declaring class is first member's native class) - Skips BetterReflection adapters during self-analysis (namespaced classes) Co-Authored-By: Claude Opus 4.6 --- src/Reflection/ClassReflection.php | 2 +- .../Php/ReflectionGetAttributesMethodReturnTypeExtension.php | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index 32bf08f53c9..3df31e38aff 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -1570,7 +1570,7 @@ private function findAttributeFlags(): ?int $attributeClass = $this->reflectionProvider->getClass(Attribute::class); $arguments = []; - foreach ($nativeAttributes[0]->getArgumentsExpressions() as $i => $expression) { // @phpstan-ignore method.notFound (method exists on BetterReflection's ReflectionAttribute adapter) + foreach ($nativeAttributes[0]->getArgumentsExpressions() as $i => $expression) { if ($i === '') { throw new ShouldNotHappenException(); } diff --git a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php index d5a21b7f624..7a731faebd4 100644 --- a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php +++ b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php @@ -14,6 +14,7 @@ use PHPStan\Type\Type; use ReflectionAttribute; use function count; +use function str_contains; final class ReflectionGetAttributesMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension { @@ -32,7 +33,8 @@ public function getClass(): string public function isMethodSupported(MethodReflection $methodReflection): bool { - return $methodReflection->getName() === 'getAttributes'; + return $methodReflection->getName() === 'getAttributes' + && !str_contains($methodReflection->getDeclaringClass()->getName(), '\\'); } public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type From d039f9028a6371085c64b6ed1cfe41c8de47bfcf Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 13:43:37 +0000 Subject: [PATCH 05/11] Resolve ReflectionAttribute class from method return type instead of str_contains hack Instead of filtering out BetterReflection adapter classes with a str_contains check in isMethodSupported(), resolve the correct ReflectionAttribute class name from the method's declared return type in getTypeFromMethodCall(). For native reflection classes, the return type is ReflectionAttribute[] so the resolved class is ReflectionAttribute. For BetterReflection adapters, the return type includes their ReflectionAttribute adapter, so the extension correctly returns GenericObjectType with that class, preserving methods like getArgumentsExpressions(). Co-Authored-By: Claude Opus 4.6 --- ...GetAttributesMethodReturnTypeExtension.php | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php index 7a731faebd4..3a1437590bb 100644 --- a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php +++ b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php @@ -11,10 +11,10 @@ use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use ReflectionAttribute; use function count; -use function str_contains; final class ReflectionGetAttributesMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension { @@ -33,8 +33,7 @@ public function getClass(): string public function isMethodSupported(MethodReflection $methodReflection): bool { - return $methodReflection->getName() === 'getAttributes' - && !str_contains($methodReflection->getDeclaringClass()->getName(), '\\'); + return $methodReflection->getName() === 'getAttributes'; } public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type @@ -45,7 +44,23 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method $argType = $scope->getType($methodCall->getArgs()[0]->value); $classType = $argType->getClassStringObjectType(); - return new IntersectionType([new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), new GenericObjectType(ReflectionAttribute::class, [$classType])), new AccessoryArrayListType()]); + $reflectionAttributeClassName = $this->resolveReflectionAttributeClassName($methodReflection); + + return new IntersectionType([new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), new GenericObjectType($reflectionAttributeClassName, [$classType])), new AccessoryArrayListType()]); + } + + private function resolveReflectionAttributeClassName(MethodReflection $methodReflection): string + { + $returnType = $methodReflection->getVariants()[0]->getReturnType(); + $nativeReflectionAttributeType = new ObjectType(ReflectionAttribute::class); + + foreach ($returnType->getIterableValueType()->getObjectClassNames() as $className) { + if ($nativeReflectionAttributeType->isSuperTypeOf(new ObjectType($className))->yes()) { + return $className; + } + } + + return ReflectionAttribute::class; } } From 7dcf3a95cfc23d20ec75124804d9161118295bca Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 13:55:14 +0000 Subject: [PATCH 06/11] Handle multiple ReflectionAttribute subclasses in return type resolution Instead of returning the first matching class name from the method's return type, collect all matches and only use the result when there is exactly one unambiguous match. When there are zero or multiple matching ReflectionAttribute subclasses, fall back to the native ReflectionAttribute class. Co-Authored-By: Claude Opus 4.6 --- .../ReflectionGetAttributesMethodReturnTypeExtension.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php index 3a1437590bb..772a0392002 100644 --- a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php +++ b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php @@ -54,12 +54,17 @@ private function resolveReflectionAttributeClassName(MethodReflection $methodRef $returnType = $methodReflection->getVariants()[0]->getReturnType(); $nativeReflectionAttributeType = new ObjectType(ReflectionAttribute::class); + $matchedClassNames = []; foreach ($returnType->getIterableValueType()->getObjectClassNames() as $className) { if ($nativeReflectionAttributeType->isSuperTypeOf(new ObjectType($className))->yes()) { - return $className; + $matchedClassNames[] = $className; } } + if (count($matchedClassNames) === 1) { + return $matchedClassNames[0]; + } + return ReflectionAttribute::class; } From dde7229bc26880453ac187e0c5d1377b3cfef279 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 14:05:06 +0000 Subject: [PATCH 07/11] Return union of GenericObjectType when multiple ReflectionAttribute subclasses match When resolveReflectionAttributeClassNames finds multiple classes that extend ReflectionAttribute, create a union of GenericObjectType for each instead of falling back to the base ReflectionAttribute class. Co-Authored-By: Claude Opus 4.6 --- ...GetAttributesMethodReturnTypeExtension.php | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php index 772a0392002..25abdcf8d3e 100644 --- a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php +++ b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php @@ -13,6 +13,7 @@ use PHPStan\Type\IntersectionType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use ReflectionAttribute; use function count; @@ -44,12 +45,20 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method $argType = $scope->getType($methodCall->getArgs()[0]->value); $classType = $argType->getClassStringObjectType(); - $reflectionAttributeClassName = $this->resolveReflectionAttributeClassName($methodReflection); + $reflectionAttributeClassNames = $this->resolveReflectionAttributeClassNames($methodReflection); - return new IntersectionType([new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), new GenericObjectType($reflectionAttributeClassName, [$classType])), new AccessoryArrayListType()]); + $valueTypes = []; + foreach ($reflectionAttributeClassNames as $className) { + $valueTypes[] = new GenericObjectType($className, [$classType]); + } + + return new IntersectionType([new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), TypeCombinator::union(...$valueTypes)), new AccessoryArrayListType()]); } - private function resolveReflectionAttributeClassName(MethodReflection $methodReflection): string + /** + * @return non-empty-list + */ + private function resolveReflectionAttributeClassNames(MethodReflection $methodReflection): array { $returnType = $methodReflection->getVariants()[0]->getReturnType(); $nativeReflectionAttributeType = new ObjectType(ReflectionAttribute::class); @@ -61,11 +70,11 @@ private function resolveReflectionAttributeClassName(MethodReflection $methodRef } } - if (count($matchedClassNames) === 1) { - return $matchedClassNames[0]; + if (count($matchedClassNames) === 0) { + return [ReflectionAttribute::class]; } - return ReflectionAttribute::class; + return $matchedClassNames; } } From 1b42b476fd7c5a906bef4befa7c50886fa2a53f9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 14:16:57 +0000 Subject: [PATCH 08/11] Rename resolveReflectionAttributeClassNames to resolveReflectionAttributeType and return Type directly Instead of returning class name strings that get wrapped in GenericObjectType by the caller, the helper method now accepts the classType parameter and returns the final GenericObjectType or UnionType directly. This avoids the unnecessary TypeCombinator::union call in the single-match case. Co-Authored-By: Claude Opus 4.6 --- ...GetAttributesMethodReturnTypeExtension.php | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php index 25abdcf8d3e..2af3715c692 100644 --- a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php +++ b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php @@ -45,36 +45,32 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method $argType = $scope->getType($methodCall->getArgs()[0]->value); $classType = $argType->getClassStringObjectType(); - $reflectionAttributeClassNames = $this->resolveReflectionAttributeClassNames($methodReflection); + $valueType = $this->resolveReflectionAttributeType($methodReflection, $classType); - $valueTypes = []; - foreach ($reflectionAttributeClassNames as $className) { - $valueTypes[] = new GenericObjectType($className, [$classType]); - } - - return new IntersectionType([new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), TypeCombinator::union(...$valueTypes)), new AccessoryArrayListType()]); + return new IntersectionType([new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $valueType), new AccessoryArrayListType()]); } - /** - * @return non-empty-list - */ - private function resolveReflectionAttributeClassNames(MethodReflection $methodReflection): array + private function resolveReflectionAttributeType(MethodReflection $methodReflection, Type $classType): Type { $returnType = $methodReflection->getVariants()[0]->getReturnType(); $nativeReflectionAttributeType = new ObjectType(ReflectionAttribute::class); - $matchedClassNames = []; + $valueTypes = []; foreach ($returnType->getIterableValueType()->getObjectClassNames() as $className) { if ($nativeReflectionAttributeType->isSuperTypeOf(new ObjectType($className))->yes()) { - $matchedClassNames[] = $className; + $valueTypes[] = new GenericObjectType($className, [$classType]); } } - if (count($matchedClassNames) === 0) { - return [ReflectionAttribute::class]; + if (count($valueTypes) === 0) { + return new GenericObjectType(ReflectionAttribute::class, [$classType]); + } + + if (count($valueTypes) === 1) { + return $valueTypes[0]; } - return $matchedClassNames; + return TypeCombinator::union(...$valueTypes); } } From a0c7ed7ab6878eb864b91c85d27a8d47368e9994 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 14:28:39 +0000 Subject: [PATCH 09/11] Use early exit in resolveReflectionAttributeType to reduce nesting Co-Authored-By: Claude Opus 4.6 --- .../ReflectionGetAttributesMethodReturnTypeExtension.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php index 2af3715c692..d4e79e192e6 100644 --- a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php +++ b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php @@ -57,9 +57,11 @@ private function resolveReflectionAttributeType(MethodReflection $methodReflecti $valueTypes = []; foreach ($returnType->getIterableValueType()->getObjectClassNames() as $className) { - if ($nativeReflectionAttributeType->isSuperTypeOf(new ObjectType($className))->yes()) { - $valueTypes[] = new GenericObjectType($className, [$classType]); + if (!$nativeReflectionAttributeType->isSuperTypeOf(new ObjectType($className))->yes()) { + continue; } + + $valueTypes[] = new GenericObjectType($className, [$classType]); } if (count($valueTypes) === 0) { From 20660403f6e12da918baf7a2f4e09c9276e2e7da Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 17 Apr 2026 14:04:47 +0000 Subject: [PATCH 10/11] Use ParametersAcceptorSelector instead of hardcoded getVariants()[0] Co-Authored-By: Claude Opus 4.6 --- .../ReflectionGetAttributesMethodReturnTypeExtension.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php index d4e79e192e6..bd0ef044919 100644 --- a/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php +++ b/src/Type/Php/ReflectionGetAttributesMethodReturnTypeExtension.php @@ -5,6 +5,7 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\ArrayType; use PHPStan\Type\DynamicMethodReturnTypeExtension; @@ -45,14 +46,14 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method $argType = $scope->getType($methodCall->getArgs()[0]->value); $classType = $argType->getClassStringObjectType(); - $valueType = $this->resolveReflectionAttributeType($methodReflection, $classType); + $variant = ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants()); + $valueType = $this->resolveReflectionAttributeType($variant->getReturnType(), $classType); return new IntersectionType([new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $valueType), new AccessoryArrayListType()]); } - private function resolveReflectionAttributeType(MethodReflection $methodReflection, Type $classType): Type + private function resolveReflectionAttributeType(Type $returnType, Type $classType): Type { - $returnType = $methodReflection->getVariants()[0]->getReturnType(); $nativeReflectionAttributeType = new ObjectType(ReflectionAttribute::class); $valueTypes = []; From 20694667b29cfc20370711b68ec653ff3a32611a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 17 Apr 2026 16:15:37 +0200 Subject: [PATCH 11/11] Update bug-14484.php --- tests/PHPStan/Analyser/nsrt/bug-14484.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14484.php b/tests/PHPStan/Analyser/nsrt/bug-14484.php index 14f70b759c3..37ca614fa40 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14484.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14484.php @@ -1,4 +1,4 @@ -= 8.0 namespace Bug14484;