Skip to content

Do not let method_exists() check fall through to general logic when object type contains template types#5605

Open
phpstan-bot wants to merge 5 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-7ulxx4w
Open

Do not let method_exists() check fall through to general logic when object type contains template types#5605
phpstan-bot wants to merge 5 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-7ulxx4w

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When using method_exists() on a template type narrowed to object&T (e.g., after an is_object() check), PHPStan incorrectly reports "will always evaluate to true". This is a false positive because an arbitrary object&T does not necessarily have any given method.

Changes

  • Added an early return null in src/Rules/Comparison/ImpossibleCheckTypeHelper.php at the end of the method_exists specific handling block, when the object type contains template or late-resolvable types
  • Added regression test in tests/PHPStan/Rules/Comparison/data/bug-8217.php reproducing the exact scenario from the issue
  • Added test method testBug8217() in tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php

Root cause

The method_exists specific handling in ImpossibleCheckTypeHelper has checks for:

  1. Concrete class names (getObjectClassNames() !== []) — checks hasMethod() directly
  2. GenericClassStringType — checks on the generic type

For object&T where T is a bare template type, neither check applies (no class names, no GenericClassStringType), so the code falls through to the general type specifier logic. The general logic uses HasMethodType::isSuperTypeOf(), which calls $type->hasMethod() on the argument. For IntersectionType([ObjectWithoutClassType, TemplateMixedType]), hasMethod() returns Yes because TemplateMixedType inherits MixedType::hasMethod() which always returns Yes. This causes the general logic to conclude the check "will always evaluate to true".

The fix adds an explicit return null when the object type contains template or late-resolvable types, correctly indicating that method existence is uncertain.

Analogous cases investigated

  • property_exists: Not affected — PropertyExistsTypeSpecifyingExtension returns empty SpecifiedTypes when finding a non-native property (which MixedType provides), so the general logic has nothing to check
  • is_callable: Not affected — tested with is_callable($object) on object&T, no false positive
  • class-string<T> templates: Not affected — handled correctly by the existing GenericClassStringType block in the specific method_exists handling
  • T of object bound: Not affected — TemplateObjectWithoutClassType::hasMethod() correctly returns Maybe (via ObjectTypeTrait), not Yes

Test

Regression test reproduces the exact scenario from the issue: a function with @template T parameter, narrowed via is_object($object), then checked with method_exists($object, 'method'). The test expects no errors (previously reported a false positive).

Fixes phpstan/phpstan#8217

… object type contains template types

- When the specific `method_exists` handling in `ImpossibleCheckTypeHelper` cannot
  determine the result (no concrete class names, no GenericClassStringType), it falls
  through to the general type specifier logic
- The general logic uses `HasMethodType::isSuperTypeOf()` which calls `hasMethod()` on
  the argument type; for `object&T` where T is a TemplateMixedType, this returns Yes
  (inherited from MixedType), causing a false positive "will always evaluate to true"
- Add early `return null` when `$objectType->hasTemplateOrLateResolvableType()` is true,
  preventing the general logic from running on types where method existence is uncertain
- Verified analogous cases: `property_exists` is not affected (its type specifying
  extension returns empty SpecifiedTypes for non-native properties); `is_callable` is
  not affected; `class-string<T>` template bounds are correctly handled by the existing
  GenericClassStringType block
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.

If the issue seems related to TemplateMixedType, why it doesn't currently occur for MixedType ?

Demonstrates that the false positive only occurs with template types
(object&T), not with plain mixed or object. For mixed,
ObjectWithoutClassType::isSuperTypeOf(MixedType) returns Maybe (mixed
is not necessarily an object), preventing the false positive. For plain
object, ObjectWithoutClassType::hasMethod() returns Maybe (via
ObjectTypeTrait), also preventing it.

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

Pushed. Here's the answer to the reviewer's question:

Why doesn't MixedType trigger the same false positive?

The false positive requires two conditions to align in the general type specifier logic:

  1. ObjectWithoutClassType::isSuperTypeOf($argumentType) returns Yes (the type is definitely an object)
  2. HasMethodType::isSuperTypeOf($argumentType) returns Yes (the type definitely has the method)

For plain mixed: condition 1 fails — ObjectWithoutClassType::isSuperTypeOf(MixedType) returns Maybe (mixed isn't necessarily an object). So the overall result is Maybe, no false positive.

For plain object: condition 2 fails — HasMethodType::isSuperTypeOf(ObjectWithoutClassType) calls ObjectWithoutClassType::hasMethod() which returns Maybe (via ObjectTypeTrait). So the overall result is Maybe, no false positive.

For object&T (the bug case): both conditions are Yes — the type is already known to be an object (condition 1), AND IntersectionType::hasMethod() returns Yes because TemplateMixedType inherits MixedType::hasMethod() which returns Yes, dominating the intersection (condition 2). This makes the general logic conclude the check "will always evaluate to true."

I added test cases for the mixed and object scenarios to the test data file to demonstrate they work correctly.

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.

Should/could the fix be done in the general specifier ?

…o return Maybe

A template type T represents a specific-but-unknown type. MixedType::hasMethod()
returns Yes (mixed encompasses all types including those with any method), but for
a template T we don't know whether the specific type has a given method.

This fixes the root cause instead of working around it in ImpossibleCheckTypeHelper.
The previous workaround checked hasTemplateOrLateResolvableType() in the
method_exists-specific handling to bail out early. With this fix, the general type
specifier logic works correctly because HasMethodType::isSuperTypeOf() now gets
Maybe (not Yes) from TemplateMixedType, so it no longer concludes that
method_exists() on object&T "will always evaluate to true."

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

Done. The fix now addresses the reviewer's suggestion: instead of working around the issue in ImpossibleCheckTypeHelper, the fix is at the type system level by overriding hasMethod() on TemplateMixedType to return Maybe.

Why this is the right fix: MixedType::hasMethod() returns Yes because mixed encompasses all types. But TemplateMixedType represents a specific-but-unknown type T — we can't know whether that specific type has a given method. Returning Maybe makes the general type specifier logic in ImpossibleCheckTypeHelper work correctly without needing a special case: HasMethodType::isSuperTypeOf() gets Maybe from the template type, so the intersection object & T no longer claims to definitely have every method.

All 11,995 tests pass and make phpstan reports no errors.

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.

List other possible fixes cause this one is not ok.

MixedType:: and TemplateMixedType methods shouldn't have different result.

…lper

Revert the hasMethod() override on TemplateMixedType so that it
inherits MixedType::hasMethod() (returning Yes) as before — the two
should not diverge.

Instead, prevent the method_exists specific handling in
ImpossibleCheckTypeHelper from falling through to the general type
specifier logic when the object type contains template or
late-resolvable types. The general logic incorrectly concludes
"always true" because MixedType::hasMethod() returns Yes, which
dominates the IntersectionType result via lazyMaxMin.

Alternatives considered:
- HasMethodType::isSuperTypeOf(): could check for template types,
  but would incorrectly return Maybe for bounded templates like
  T of SomeClass where the method genuinely exists on the bound.
- IntersectionType::hasMethod(): changing from lazyMaxMin to lazyMin
  would be a large behavioral change affecting all intersections.
- General result processing: capping results at Maybe for template
  types is equivalent to this fix but less targeted.

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

Pushed. The fix now addresses the reviewer's concern:

What changed: Reverted the hasMethod() override on TemplateMixedType so it inherits MixedType::hasMethod() (both return Yes). Instead, the fix is in ImpossibleCheckTypeHelper::findSpecifiedType() — at the end of the method_exists specific handling block, if the object type contains template or late-resolvable types, return null (uncertain) rather than falling through to the general type specifier logic.

Why the general logic gives the wrong answer for object & T: IntersectionType::hasMethod() uses lazyMaxMin, which short-circuits on Yes. Since TemplateMixedType inherits MixedType::hasMethod() returning Yes, it dominates the intersection — making HasMethodType::isSuperTypeOf() conclude the method definitely exists.

Alternatives considered:

  1. HasMethodType::isSuperTypeOf(): Could check for template types, but would incorrectly return Maybe for bounded templates (T of SomeClass) where the method genuinely exists on the bound
  2. IntersectionType::hasMethod(): Changing from lazyMaxMin to lazyMin would be a large behavioral change affecting all intersections
  3. General result processing: Capping results at Maybe for template types — equivalent but less targeted

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.

I feel like the fix should be inside HasMethodType::isSuperTypeOf() instead.

For instance HasMethodType::isSuperTypeOf(Mixed) should be Maybe only (this would be consistent with MixedType::isSubtypeOf(HasMethod) which is maybe)

…ompoundTypes

HasMethodType::isSuperTypeOf(MixedType) was returning Yes (via
MixedType::hasMethod() which always returns Yes), inconsistent with
MixedType::isSubTypeOf(HasMethodType) which returns Maybe. This caused
false positives for method_exists() on template types like object&T,
where TemplateMixedType (inheriting MixedType::hasMethod()) dominated
IntersectionType::hasMethod() via lazyMaxMin.

The fix delegates to $type->isSubTypeOf($this) for CompoundType
instances (the standard pattern used by other accessory types like
AccessoryNonEmptyStringType). When isSubTypeOf returns No, we fall
back to hasMethod() — this prevents AccessoryTypes in intersections
(e.g. AccessoryLiteralStringType in class-string&literal-string) from
being overly strict, since their isSubTypeOf checks orthogonal
properties that can incorrectly poison the lazyMaxMin result.

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

I processed this review but have nothing to report.

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