Skip to content

Do not treat method_exists() as always true for @method-annotated methods#5664

Open
phpstan-bot wants to merge 3 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-6o6iiqe
Open

Do not treat method_exists() as always true for @method-annotated methods#5664
phpstan-bot wants to merge 3 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-6o6iiqe

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When an interface or class has a @method PHPDoc annotation, PHPStan incorrectly reports method_exists() checks as always evaluating to true. Since @method-annotated methods are virtual (typically handled by __call()) and don't exist as actual PHP methods, method_exists() may return false at runtime.

Changes

  • Modified src/Rules/Comparison/ImpossibleCheckTypeHelper.php:
    • For object types: when hasMethod() returns yes, additionally verify via ClassReflection::hasNativeMethod() that the method is actually declared in PHP code, not just via @method annotation. Return null (indeterminate) instead of true when only a @method annotation exists.
    • For generic class-string types: applied the same hasNativeMethod() guard before reporting always-true.
  • Added test data at tests/PHPStan/Rules/Comparison/data/bug-6211.php covering:
    • @method on an interface, checked via object instance and class string
    • @method on a class directly
    • Native methods still correctly reported as always-true
    • @property with __get() correctly handled (already working via PropertyExistsTypeSpecifyingExtension)

Root cause

ImpossibleCheckTypeHelper::findSpecifiedType() used $objectType->hasMethod() to determine if method_exists() would always return true. hasMethod() delegates to ClassReflection::hasMethod(), which includes methods from AnnotationsMethodsClassReflectionExtension (i.e., @method PHPDoc annotations). Since these annotations describe methods that may be handled by __call() but don't create real PHP methods, method_exists() should not be considered always-true for them.

The fix uses ClassReflection::hasNativeMethod() to verify the method is an actual PHP method before reporting the check as always-true.

Analogous cases probed

  • property_exists() with @property: Already correctly handled — PropertyExistsTypeSpecifyingExtension checks $propertyReflection->isNative() at line 87 and returns empty specifiers for non-native properties.
  • method_exists() with class-string: Fixed in the same PR — the generic class-string code path in ImpossibleCheckTypeHelper had the same issue.

Test

Regression test testBug6211 in ImpossibleCheckTypeFunctionCallRuleTest with test data covering @method on interfaces and classes, both via object instances and class strings, alongside native methods that should still be reported as always-true.

Fixes phpstan/phpstan#6211

… methods

- In `ImpossibleCheckTypeHelper::findSpecifiedType()`, the `method_exists()`
  check used `hasMethod()` which includes `@method` PHPDoc annotations.
  Since `@method` methods are virtual (handled by `__call()`), they don't
  guarantee `method_exists()` returns true at runtime.
- Added `hasNativeMethod()` check for object types to only report always-true
  when the method is actually declared in PHP code, not just via `@method`.
- Applied the same fix for the generic class-string code path.
- Verified that `property_exists()` already handles `@property` correctly
  via `PropertyExistsTypeSpecifyingExtension::isNative()` check.
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.

The rule should still report a method_exists already specified to true.

// @method should not make method_exists always true
if (\method_exists($hell, 'isTrue')) {
    if (\method_exists($hell, 'isTrue')) { // This one should be reported

     }
}

Also if we finally check native type ; is it still needed to check hasMethod($methodType->getValue())->yes() before ?

When inside an `if (method_exists(...))` block, a repeated
method_exists() check for the same object and method is redundant and
should be reported as always-true, even for @method-annotated methods.

To detect this, the MethodExistsTypeSpecifyingExtension now also tracks
the method_exists() FuncCall expression as ConstantBooleanType(true) in
the scope (in addition to narrowing the argument type). This allows
ImpossibleCheckTypeHelper to detect the nested case via
Scope::hasExpressionType() before any type-specific logic runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Here's a summary of the changes made to address the review:

Review point 1: Nested method_exists() should still be reported when already specified to true

Two coordinated changes:

  1. MethodExistsTypeSpecifyingExtension: Now also specifies the method_exists() FuncCall expression as ConstantBooleanType(true) in the scope (via unionWith), in addition to narrowing the argument type. This makes the scope track that method_exists(...) was already established as true inside an if (method_exists(...)) block.

  2. ImpossibleCheckTypeHelper: Added an early $scope->hasExpressionType($node)->yes() check at the top of the constant-method-name block. This detects nested method_exists() calls that were already established by a prior check and returns true (always-true), regardless of whether the method is native or from @method. This handles both object instances and class-string arguments uniformly.

Review point 2: "Is hasMethod() check still needed before hasNativeMethod()?"

Yes, it's still needed. The hasMethod()->yes() guard distinguishes three cases: (a) native method → return true, (b) @method only → return null, (c) method doesn't exist → fall through to hasMethod()->no() which returns false. Without it, @method methods would fall through to general logic which incorrectly reports them as always-true.

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.

Does the issue exists for property_exists ; if not, why ? Can't the method_exist behave the same way ?


if ($methodType instanceof ConstantStringType) {
if ($scope->hasExpressionType($node)->yes()) {
return true;
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 feel wrong in a way that if an expressionType exists with the value "false" we still return...

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. Here's a summary of the changes:

Addressing the review comment: "Does the issue exist for property_exists; if not, why? Can't the method_exist behave the same way?"

The answer is: property_exists already handles this correctly because PropertyExistsTypeSpecifyingExtension checks $propertyReflection->isNative() and returns empty SpecifiedTypes for @property annotations (line 87). This prevents the type from being narrowed, so the check is never reported as always-true.

I've now made method_exists follow the same pattern:

  1. MethodExistsTypeSpecifyingExtension: Added ReflectionProvider dependency and hasNativeMethod() checks, mirroring PropertyExistsTypeSpecifyingExtension. When the method exists only via @method annotation, returns empty SpecifiedTypes([], []) — no type narrowing occurs.

  2. ImpossibleCheckTypeHelper: Simplified the hasNativeMethod() check (since the type specifier now handles the primary case). Removed the $scope->hasExpressionType() early return that the reviewer flagged as wrong (it would return true even if the expression was false). Removed the funcCallSpec union approach.

  3. Test data: Removed nested method_exists() test cases (no longer relevant since the type isn't narrowed for @method methods, matching property_exists behavior).

…ropertyExistsTypeSpecifyingExtension

Move the @method annotation check into MethodExistsTypeSpecifyingExtension
to match how PropertyExistsTypeSpecifyingExtension handles @Property
annotations. When the method only exists via @method PHPDoc, return empty
SpecifiedTypes so the type is not narrowed. Simplify ImpossibleCheckTypeHelper
accordingly and remove the nested funcCallSpec approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

property_exists is not statisfying cause it reports

if (property_exists($task, $a)) {
			if (property_exists($task, $a)) {
			
			}
		}

but not

if (property_exists($task, '$a')) {
			if (property_exists($task, '$a')) {
			
			}
		}

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.

property_exists is not statisfying cause it reports

if (property_exists($task, $a)) {
			if (property_exists($task, $a)) {
			
			}
		}

but not

if (property_exists($task, '$a')) {
			if (property_exists($task, '$a')) {
			
			}
		}

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