Skip to content

Fix ReflectionGetAttributesMethodReturnTypeExtension for UnionTypes#5463

Merged
staabm merged 11 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-7l9f0pj
Apr 17, 2026
Merged

Fix ReflectionGetAttributesMethodReturnTypeExtension for UnionTypes#5463
staabm merged 11 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-7l9f0pj

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

This fix risk to transform GenericObjectType or TemplateObjectType into simple ObjectType. Would it make sens to iterate on the union type ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Here's what I changed to address the reviewer's concern:

Problem: The previous fix used new ObjectType($className) which strips generic type parameters — e.g., ReflectionClass<*> would become plain ReflectionClass, and TemplateObjectType would lose its template information.

Fix: Instead of iterating over $typeWithMethod->getObjectClassNames() and reconstructing plain ObjectTypes, the code now:

  1. Splits the type into its constituent parts: $typeWithMethod instanceof UnionType ? $typeWithMethod->getTypes() : [$typeWithMethod]
  2. For each inner type, gets class names via $innerType->getObjectClassNames()
  3. Gets method reflection from the original inner type ($innerType->getMethod(...)) which preserves GenericObjectType, TemplateObjectType, etc.
  4. Still iterates over class names for extension lookup (since extensions are registered per class name)

Both make tests and make phpstan pass cleanly.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Try to not touch MethodCallReturnTypeHelper and remove the $methodReflection->getDeclaringClass()->getName() check inside the ReflectionGetAttributesMethodReturnTypeExtension instead.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Here's a summary of the changes:

What changed:

  1. Reverted MethodCallReturnTypeHelper.php back to its original state — no more union type inner type iteration or per-class method reflection.

  2. Simplified ReflectionGetAttributesMethodReturnTypeExtension::isMethodSupported() — removed the $methodReflection->getDeclaringClass()->getName() === $this->className check, leaving only the method name check (getName() === 'getAttributes'). This is safe because the getClass() method + extension registry already ensures each extension instance is only invoked for the correct class hierarchy.

  3. Added @phpstan-ignore method.notFound on ClassReflection.php:1573 — the getArgumentsExpressions() method exists on BetterReflection's ReflectionAttribute adapter but not on native PHP's ReflectionAttribute. Removing the declaring class check causes the extension to fire on PHPStan's self-analysis (where BetterReflection overrides getAttributes() with declaring class PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass instead of ReflectionClass), exposing this pre-existing type mismatch.

Why this works: The declaring class check was redundant because DynamicReturnTypeExtensionRegistry::getDynamicExtensionsForType() already walks the class hierarchy when looking up extensions. The check was also the root cause of the union type bug — UnionTypeMethodReflection::getDeclaringClass() returns only the first member's declaring class, so extensions registered for other union members were never matched.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

I processed this review but have nothing to report.

Comment thread src/Reflection/ClassReflection.php Outdated
$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)
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.

Rather than ignoring the method.notFOund error

Use

@var array<ReflectionAttribute>

above the $nativeAttributes var definition

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done. Here's a summary of what I changed and why:

The reviewer's suggestion (@var array<ReflectionAttribute>) doesn't work because removing the declaring class check entirely causes the extension to fire during self-analysis. When it fires, it overrides BetterReflection's return type (list<BetterReflection\ReflectionAttribute>) with the native type (list<ReflectionAttribute<Attribute>>). The @var then fails PHPStan's varTag.type rule because BetterReflection\ReflectionAttribute (which is CoreReflectionAttribute<object>) is not a subtype of ReflectionAttribute<Attribute>.

Root cause investigation: Through debug logging, I discovered:

  • During self-analysis: getDeclaringClass()->getName() returns PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass (adapter overrides getAttributes())
  • For user code: declaring class is native ReflectionClass
  • For union types: UnionTypeMethodReflection returns the first member's declaring class (always native)

The fix: Replace !str_contains($methodReflection->getDeclaringClass()->getName(), '\\') — this check:

  • Passes for native reflection classes (user code)
  • Passes for union types (first member is native)
  • Fails for BetterReflection adapters (namespaced), preserving their accurate return types

This removes both the @phpstan-ignore from ClassReflection.php and the fragile === $this->className check that caused the union type bug.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

Rather than a check

!str_contains($methodReflection->getDeclaringClass()->getName(), '\\');

what about improving getTypeFromMethodCall for BetterReflection classes.

This will be a

GenericObjectType(BetterReflectionAttribute

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Here's what I changed to address the reviewer's feedback:

Problem: The previous approach used !str_contains($methodReflection->getDeclaringClass()->getName(), '\\') in isMethodSupported() as a hack to skip BetterReflection adapter classes. The reviewer suggested improving getTypeFromMethodCall() instead.

Fix:

  1. Simplified isMethodSupported() — now only checks getName() === 'getAttributes', no declaring class filtering
  2. Added resolveReflectionAttributeClassName() — inspects the method's declared return type to determine which ReflectionAttribute class to use in the GenericObjectType:
    • For native reflection classes: return type is ReflectionAttribute[] → resolves to ReflectionAttribute
    • For BetterReflection adapters: return type is list<BRAdapter\ReflectionAttribute|FakeReflectionAttribute> → resolves to BRAdapter\ReflectionAttribute (which extends native ReflectionAttribute), preserving methods like getArgumentsExpressions()

This approach uses ObjectType::isSuperTypeOf() to find the correct class from the iterable value type, which is type-system-native and handles the class hierarchy properly.

Comment on lines +57 to +61
foreach ($returnType->getIterableValueType()->getObjectClassNames() as $className) {
if ($nativeReflectionAttributeType->isSuperTypeOf(new ObjectType($className))->yes()) {
return $className;
}
}
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.

This would creates an issue with a method which returns something like array<Attribute1|Attribute2> since you're matching the first one only.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both make tests and make phpstan pass cleanly.

Summary of the change:

In resolveReflectionAttributeClassName, instead of returning the first class name from the method's return type that is a subtype of ReflectionAttribute, the code now collects all matching class names and only uses the result when there is exactly one unambiguous match. When there are zero or multiple matches (e.g., a return type like array<Attribute1|Attribute2> where both extend ReflectionAttribute), it falls back to the native ReflectionAttribute::class.

This handles the existing cases correctly:

  • Native reflection: return type value is ReflectionAttribute → one match → uses it
  • BetterReflection adapters: return type value is BRAdapter\ReflectionAttribute|FakeReflectionAttribute → one match (only the adapter extends native ReflectionAttribute) → uses it
  • Hypothetical Attribute1|Attribute2: two matches → falls back to ReflectionAttribute::class (the common base)

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

When there is multiple classes, it should be an union of all the

new GenericObjectType($classes, [$classType])

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Here's what I changed to address the reviewer's feedback:

Change: When resolveReflectionAttributeClassNames finds multiple classes that extend ReflectionAttribute, instead of falling back to the base ReflectionAttribute::class, the code now returns all matching class names and creates a union of GenericObjectType for each one using TypeCombinator::union().

For example, if a method returns array<Attribute1|Attribute2> where both extend ReflectionAttribute, the result will be list<ReflectionAttribute<I>|ReflectionAttribute2<I>> (a union of generic types) rather than just list<ReflectionAttribute<I>>.

The single-match case still works the same — TypeCombinator::union() with one type just returns that type.

Both make tests and make phpstan pass cleanly.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

What if resolveReflectionAttributeClassNames is renamed resolveReflectionAttribute and return directly the GenericObjectType or the UnionType to avoid a useless TypeCombinator::union call.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. The change renames resolveReflectionAttributeClassNames to resolveReflectionAttributeType and has it return the Type directly (GenericObjectType for single match, UnionType via TypeCombinator::union for multiple matches) instead of returning class name strings. This avoids the unnecessary TypeCombinator::union call in the common single-match case. Both make tests and make phpstan pass cleanly.

@VincentLanglet VincentLanglet changed the title Use per-class method reflection when resolving dynamic return type extensions on union types Fix ReflectionGetAttributesMethodReturnTypeExtension for UnionTypes Apr 14, 2026
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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


FOUND 1 ERROR AFFECTING 1 LINE

60 | ERROR | [x] Use early exit to reduce code nesting.
| | (SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed)

PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. The fix uses early exit (continue with negated condition) in the foreach loop at line 60 of ReflectionGetAttributesMethodReturnTypeExtension.php to reduce nesting, addressing the SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed coding standard violation. Both make tests and make phpstan pass cleanly.

@VincentLanglet
Copy link
Copy Markdown
Contributor

WDYT @janedbal ?

@VincentLanglet VincentLanglet requested a review from staabm April 14, 2026 14:49

private function resolveReflectionAttributeType(MethodReflection $methodReflection, Type $classType): Type
{
$returnType = $methodReflection->getVariants()[0]->getReturnType();
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.

why do we only need to handle the 1st variant?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Pushed. The change replaces $methodReflection->getVariants()[0]->getReturnType() with ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(), which properly selects the matching variant based on actual call arguments instead of hardcoding index [0]. This follows the standard pattern used across other dynamic return type extensions in the codebase. Tests and phpstan both pass.

Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

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

lgtm - when/how did this break with 2.1.47+ ?

@VincentLanglet
Copy link
Copy Markdown
Contributor

lgtm - when/how did this break with 2.1.47+ ?

Previously ReflectionClass|ReflectionMethod was handled by the returnTypeDynamicExtension this way.

  • Find a match for ReflectionClass get the return type
  • Find no match for ReflectionMethod ignore
  • Return the result

In 2.1.47, we fix the fact that since ReflectionMethod was ignored we should still add the return type of ReflectionMethod.

Now we solve the issue by fixing the ReflectiongetAttributesMethod to correctly match ReflectionMethod too. And since all type are handled we don't use the default return type anymore.

phpstan-bot and others added 7 commits April 17, 2026 16:10
…tensions 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.
… 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 <noreply@anthropic.com>
…xtension 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ubclasses 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 <noreply@anthropic.com>
phpstan-bot and others added 3 commits April 17, 2026 16:10
…buteType 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-7l9f0pj branch from aeaf0f7 to 2066040 Compare April 17, 2026 14:10
@staabm staabm merged commit 980527c into phpstan:2.1.x Apr 17, 2026
649 of 657 checks passed
@staabm
Copy link
Copy Markdown
Contributor

staabm commented Apr 17, 2026

thank you

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.

3 participants