Skip to content

Recognize [$obj, $method] as callable when method_exists($obj, $method) is known true in scope#5547

Open
phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-d510b1p
Open

Recognize [$obj, $method] as callable when method_exists($obj, $method) is known true in scope#5547
phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-d510b1p

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When method_exists($obj, $method) confirms a method exists and $method is not a constant string, PHPStan still reports "Trying to invoke array{Foo, string} but it might not be a callable" when [$obj, $method]() is invoked. This is a false positive because the method_exists check has already verified the method exists.

The same false positive occurs with is_callable([$obj, $method]) for inline arrays with non-constant method names.

Changes

  • Modified src/Analyser/ExprHandler/ArrayHandler.php to check whether method_exists() is known to be true in scope when resolving the type of a 2-element array literal
  • When method_exists(item0, item1) is true in scope and the array's isCallable() returns maybe, intersect the array type with CallableType to confirm callability
  • Added regression test in tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php and tests/PHPStan/Rules/Functions/data/bug-4510.php
  • Added type inference test in tests/PHPStan/Analyser/nsrt/bug-4510.php

Root cause

MethodExistsTypeSpecifyingExtension handles non-constant method names by storing method_exists($obj, $method) → true in the scope's expression types. However, when [$obj, $method] is later constructed as an array literal, ArrayHandler::resolveType() computed the type purely from element types without considering scope conditions. The ConstantArrayType::isCallable() method returned maybe for non-constant method names, triggering the CallCallablesRule false positive.

The fix bridges this gap: ArrayHandler now constructs a synthetic method_exists() call from the array elements and checks whether the scope already knows it to be true.

Analogous cases probed

  • is_callable([$obj, $method]) inline array: Fixed by the same mechanism — IsCallableFunctionTypeSpecifyingExtension delegates to MethodExistsTypeSpecifyingExtension which stores method_exists() → true
  • $this as first element: Works — expression key matches
  • Class-string first element ([Foo::class, $method]): Works — expression key matches
  • is_callable($variable): Already worked before this fix (variable narrowing via CallableType)
  • method_exists in if/else branches: Works — scope correctly has the expression type in the true branch
  • Union of constant strings ('foo'|'bar'): Not affected — when both methods don't exist on the class, isCallable() returns no (not maybe), which is correct behavior
  • property_exists: Different axis — property access is not callable, no change needed

Test

  • Rule test: testBug4510 in CallCallablesRuleTest — verifies no false positive errors for method_exists + [$obj, $method](), is_callable([$obj, $method]) + [$obj, $method](), class-string variant, $this variant, variable variant (already worked), and if-else branch variant
  • NSRT test: tests/PHPStan/Analyser/nsrt/bug-4510.php — verifies the inferred types are intersection of array type with callable(): mixed

Fixes phpstan/phpstan#4510

…thod)` is known true in scope

- In `ArrayHandler::resolveType()`, after computing the array type, check if
  the 2-element array's `isCallable()` returns `maybe` and whether
  `method_exists(item0, item1)` is known to be `true` in the current scope
- When both conditions are met, intersect the array type with `CallableType`,
  making `isCallable()` return `yes` instead of `maybe`
- This fixes false positives from `CallCallablesRule` for patterns like:
  `method_exists($obj, $method)` followed by `[$obj, $method]()`
- Also fixes the same pattern via `is_callable([$obj, $method])` for inline
  arrays, since `IsCallableFunctionTypeSpecifyingExtension` delegates to
  `MethodExistsTypeSpecifyingExtension` which stores the `method_exists`
  result in scope
- Tested with: `$this`, class-string first element, `is_callable` inline
  array, and if/else branching patterns
Comment on lines +51 to +53
count($expr->items) === 2
&& $expr->items[0]->key === null
&& $expr->items[1]->key === null
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.

I feel like we don't need all these check since ConstantArrayType will already do it in isCallable.

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. Removed the redundant count($expr->items) === 2, $expr->items[0]->key === null, and $expr->items[1]->key === null checks as the reviewer suggested — ConstantArrayType::isCallable() already validates the array structure, so $type->isCallable()->maybe() is sufficient. Kept isset($expr->items[0], $expr->items[1]) for safe AST access. All tests and static analysis pass.

ConstantArrayType::isCallable() already validates that the array has
exactly 2 elements with keys 0 and 1, so the explicit count and key
checks are unnecessary when isCallable()->maybe() is used as a guard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@stof
Copy link
Copy Markdown
Contributor

stof commented Apr 27, 2026

the fact that a method exists does not mean it is callable: https://3v4l.org/7cKRe#v

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 fact a method exist doesn't imply it's callable since the method might be private for instance.

So only

function testIsCallableInlineArray(string $method): void {
	$instance = new Foo();
	if (!is_callable([$instance, $method])) {
		return;
	}

	assertType('list{Bug4510Nsrt\Foo, string}&callable(): mixed', [$instance, $method]);
}

is valid.

…n true, not method_exists()

method_exists() confirms a method exists but does not verify callability
(e.g. private methods exist but are not callable from outside the class).
Only is_callable([$obj, $method]) truly verifies that the array is callable.

- Modified IsCallableFunctionTypeSpecifyingExtension to store an additional
  is_callable marker expression in scope alongside the method_exists delegation
- Changed ArrayHandler to check for this is_callable marker (using
  hasExpressionType to avoid evaluating the synthetic call as a real function)
- Updated tests: method_exists cases now correctly report "might not be callable"

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

Pushed. Here's a summary of the changes:

Problem: The reviewer correctly pointed out that method_exists() returning true doesn't mean the method is callable — it could be private/protected. Only is_callable() truly verifies callability.

Root cause: Both is_callable([$obj, $method]) and method_exists($obj, $method) stored the same expression in scope (method_exists(...)→true), so ArrayHandler couldn't distinguish between them.

Fix (3 parts):

  1. IsCallableFunctionTypeSpecifyingExtension: When handling is_callable([$obj, $method]), now stores an additional is_callable marker expression in scope (alongside the existing method_exists delegation for HasMethodType narrowing)
  2. ArrayHandler: Checks for the is_callable marker instead of method_exists. Uses hasExpressionType() first to avoid evaluating the synthetic is_callable call as a real function (which would cause false positives for arrays like ['rand', null])
  3. Tests: Updated so method_exists cases correctly report "might not be a callable", while is_callable cases are accepted


if (
$type->isCallable()->maybe()
&& isset($expr->items[0], $expr->items[1])
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.

should we limit this to 2 item arrays only?
that way we would not need to check any literal array we find, whether its callable

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. Added count($expr->items) === 2 as an early guard before the isCallable()->maybe() check, so we only evaluate callable-related logic on arrays that could actually be [$obj, $method] pairs. Tests and static analysis both pass.

Only arrays with exactly 2 elements can be [$obj, $method] callable
pairs, so skip the isCallable() evaluation for all other array sizes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
if (
count($expr->items) === 2
&& $type->isCallable()->maybe()
&& isset($expr->items[0], $expr->items[1])
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.

isset($expr->items[0], $expr->items[1]) is faster than $type->isCallable()->maybe() so you can swap the condition.

Also, how are indexed the item ?
Cause array{0: string, 1: string} might be callable but array{1: string, 2: string} won't.

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 VincentLanglet's review:

  1. Reordered conditions in ArrayHandler: Moved isset($expr->items[0], $expr->items[1]) and the new key null checks before $type->isCallable()->maybe() since they're cheaper to evaluate.

  2. Added key null checks: Added $expr->items[0]->key === null && $expr->items[1]->key === null to ensure we only consider auto-indexed arrays ([0: ..., 1: ...]), not explicitly-keyed arrays like [1: ..., 2: ...] which wouldn't be callable.

  3. Inlined isCallableMarker variable in IsCallableFunctionTypeSpecifyingExtension as requested in the previous review.

All tests (11,927) and static analysis pass.

return $this->methodExistsExtension->specifyTypes($functionReflection, $functionCall, $scope, $context);
$methodExistsTypes = $this->methodExistsExtension->specifyTypes($functionReflection, $functionCall, $scope, $context);

$isCallableMarker = $this->typeSpecifier->create(
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.

isCallableMarker var is not needed

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.

The remote already has the commit addressing this review. My rebase resolved cleanly to the same state (no diff). The changes are already pushed:

  1. Inlined $isCallableMarker in IsCallableFunctionTypeSpecifyingExtension.php (VincentLanglet's current review comment)
  2. Reordered conditions in ArrayHandler.php — cheap isset() and key-null checks before expensive isCallable()->maybe() (VincentLanglet's previous comment)
  3. Added key-null checks ($expr->items[0]->key === null && $expr->items[1]->key === null) to ensure we only handle auto-indexed [$obj, $method] arrays, not explicitly-keyed ones like [1 => $a, 0 => $b] (VincentLanglet's indexing concern)

All tests pass and static analysis is clean.

…isCallableMarker

- Move cheap isset() and key null checks before expensive isCallable()->maybe()
- Verify array items have null keys (auto-indexed 0,1) to exclude non-sequential arrays
- Inline isCallableMarker variable in IsCallableFunctionTypeSpecifyingExtension

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.

Add test for the array

[0 => 'FooClass', 1 => 'method']

which should work.


public function testBug4510(): void
{
$this->analyse([__DIR__ . '/data/bug-4510.php'], [
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.

Remove the data/bug-4510 and analyse the nsrt file instead

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.

The branch is up to date with the remote and all review comments are addressed:

  1. "Remove the data/bug-4510 and analyse the nsrt file instead" - Done. tests/PHPStan/Rules/Functions/data/bug-4510.php is deleted. CallCallablesRuleTest::testBug4510() now analyses tests/PHPStan/Analyser/nsrt/bug-4510.php which serves as both the NSRT type inference test and the rule test data file.

  2. "Add test for [0 => 'FooClass', 1 => 'method']" (from previous review) - Done. testIsCallableExplicitKeys and testIsCallableExplicitKeysWithClassString test explicit 0 =>/1 => keys. The key null checks were removed from ArrayHandler since isCallable()->maybe() already validates the array structure.

All 11,927 tests pass and static analysis reports no errors. The commits are already pushed to the remote.

…check

Remove the key === null restriction in ArrayHandler since
isCallable()->maybe() already validates the array structure (keys 0 and 1).
This allows [0 => $obj, 1 => $method] to be recognized as callable
when is_callable() is known true in scope, not just [$obj, $method].

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -0,0 +1,58 @@
<?php declare(strict_types = 1);

namespace Bug4510Nsrt;
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.

Merge the nsrt and data bug-4510 file into a single one which is loaded for both the nsrt and rule tests

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 what changed:

  1. Merged test files: Combined tests/PHPStan/Rules/Functions/data/bug-4510.php and tests/PHPStan/Analyser/nsrt/bug-4510.php into a single file at the nsrt location, used by both the NSRT type inference test and the CallCallablesRuleTest rule test.

  2. Added explicit keys + class-string test: New testIsCallableExplicitKeysWithClassString covers [0 => HelloWorld::class, 1 => $method] as VincentLanglet requested.

  3. Updated rule test path: CallCallablesRuleTest::testBug4510() now references __DIR__ . '/../../Analyser/nsrt/bug-4510.php' with corrected line numbers.

  4. Deleted old data file: Removed the separate tests/PHPStan/Rules/Functions/data/bug-4510.php.

All 11,927 tests pass and static analysis reports no errors.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The monitor timed out after the tests already completed — no action needed, everything was already committed and pushed.

… rule tests

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.


FOUND 2 ERRORS AFFECTING 2 LINES

385 | ERROR | [x] String "Trying to invoke array{Bug4510\HelloWorld, string}
| | but it might not be a callable." does not require double
| | quotes; use single quotes instead
| | (Squiz.Strings.DoubleQuoteUsage.NotRequired)
393 | ERROR | [x] String "Trying to invoke array{Bug4510\HelloWorld, string}
| | but it might not be a callable." does not require double
| | quotes; use single quotes instead
| | (Squiz.Strings.DoubleQuoteUsage.NotRequired)

PHPCBF CAN FIX THE 2 MARKED SNIFF VIOLATIONS AUTOMATICALLY

…lCallablesRuleTest

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

Fixed the 2 coding standard violations: replaced double quotes with single quotes on lines 385 and 393 of CallCallablesRuleTest.php, since \H isn't a PHP escape sequence and doesn't require double quotes. Tests and static analysis both pass.

@VincentLanglet VincentLanglet requested a review from staabm April 27, 2026 17:43
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.

4 participants