From 819b1a19e1d3bc11014a154bbeb284e9be04736e Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:08:21 +0000 Subject: [PATCH 01/11] Recognize `[$obj, $method]` as callable when `method_exists($obj, $method)` 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 --- src/Analyser/ExprHandler/ArrayHandler.php | 27 ++++++++- tests/PHPStan/Analyser/nsrt/bug-4510.php | 41 +++++++++++++ .../Rules/Functions/CallCallablesRuleTest.php | 5 ++ .../PHPStan/Rules/Functions/data/bug-4510.php | 57 +++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-4510.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-4510.php diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index a4d150b0e2f..10d442b69e2 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -2,8 +2,11 @@ namespace PHPStan\Analyser\ExprHandler; +use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; +use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; @@ -15,8 +18,12 @@ use PHPStan\Node\LiteralArrayItem; use PHPStan\Node\LiteralArrayNode; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\Type\CallableType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function array_merge; +use function count; /** * @implements ExprHandler @@ -38,7 +45,25 @@ public function supports(Expr $expr): bool public function resolveType(MutatingScope $scope, Expr $expr): Type { - return $this->initializerExprTypeResolver->getArrayType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); + $type = $this->initializerExprTypeResolver->getArrayType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); + + if ( + count($expr->items) === 2 + && $expr->items[0]->key === null + && $expr->items[1]->key === null + && $type->isCallable()->maybe() + ) { + $methodExistsCall = new FuncCall( + new FullyQualified('method_exists'), + [new Arg($expr->items[0]->value), new Arg($expr->items[1]->value)], + ); + $methodExistsType = $scope->getType($methodExistsCall); + if ((new ConstantBooleanType(true))->isSuperTypeOf($methodExistsType)->yes()) { + $type = TypeCombinator::intersect($type, new CallableType()); + } + } + + return $type; } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult diff --git a/tests/PHPStan/Analyser/nsrt/bug-4510.php b/tests/PHPStan/Analyser/nsrt/bug-4510.php new file mode 100644 index 00000000000..daaf03a2c3d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-4510.php @@ -0,0 +1,41 @@ +analyse([__DIR__ . '/data/bug-4510.php'], []); + } + public function testMaybeNotCallable(): void { $errors = []; diff --git a/tests/PHPStan/Rules/Functions/data/bug-4510.php b/tests/PHPStan/Rules/Functions/data/bug-4510.php new file mode 100644 index 00000000000..ec41d9f41c1 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-4510.php @@ -0,0 +1,57 @@ + Date: Sun, 26 Apr 2026 20:15:33 +0000 Subject: [PATCH 02/11] Remove redundant array structure checks in ArrayHandler 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 --- src/Analyser/ExprHandler/ArrayHandler.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 10d442b69e2..5df7b5a858c 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -23,7 +23,6 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_merge; -use function count; /** * @implements ExprHandler @@ -48,10 +47,8 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type $type = $this->initializerExprTypeResolver->getArrayType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); if ( - count($expr->items) === 2 - && $expr->items[0]->key === null - && $expr->items[1]->key === null - && $type->isCallable()->maybe() + $type->isCallable()->maybe() + && isset($expr->items[0], $expr->items[1]) ) { $methodExistsCall = new FuncCall( new FullyQualified('method_exists'), From 62e36add3ef56a090be6b29708efedc5fcc0356b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 27 Apr 2026 09:08:21 +0000 Subject: [PATCH 03/11] Only recognize [$obj, $method] as callable when is_callable() is known 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 --- src/Analyser/ExprHandler/ArrayHandler.php | 10 +++--- ...allableFunctionTypeSpecifyingExtension.php | 16 ++++++++- tests/PHPStan/Analyser/nsrt/bug-4510.php | 10 +++++- .../Rules/Functions/CallCallablesRuleTest.php | 19 +++++++++- .../PHPStan/Rules/Functions/data/bug-4510.php | 36 +++++++++++++++---- 5 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 5df7b5a858c..49c386df5cb 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -50,12 +50,14 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type $type->isCallable()->maybe() && isset($expr->items[0], $expr->items[1]) ) { - $methodExistsCall = new FuncCall( - new FullyQualified('method_exists'), + $isCallableCall = new FuncCall( + new FullyQualified('is_callable'), [new Arg($expr->items[0]->value), new Arg($expr->items[1]->value)], ); - $methodExistsType = $scope->getType($methodExistsCall); - if ((new ConstantBooleanType(true))->isSuperTypeOf($methodExistsType)->yes()) { + if ( + $scope->hasExpressionType($isCallableCall)->yes() + && (new ConstantBooleanType(true))->isSuperTypeOf($scope->getType($isCallableCall))->yes() + ) { $type = TypeCombinator::intersect($type, new CallableType()); } } diff --git a/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php b/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php index a0cde666e5b..42b8d5b7e80 100644 --- a/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php @@ -6,6 +6,7 @@ use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Name; +use PhpParser\Node\Name\FullyQualified; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -15,6 +16,7 @@ use PHPStan\Reflection\FunctionReflection; use PHPStan\ShouldNotHappenException; use PHPStan\Type\CallableType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\FunctionTypeSpecifyingExtension; use function count; use function strtolower; @@ -57,7 +59,19 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n new Arg($value->items[0]->value), new Arg($value->items[1]->value), ]); - return $this->methodExistsExtension->specifyTypes($functionReflection, $functionCall, $scope, $context); + $methodExistsTypes = $this->methodExistsExtension->specifyTypes($functionReflection, $functionCall, $scope, $context); + + $isCallableMarker = $this->typeSpecifier->create( + new FuncCall(new FullyQualified('is_callable'), [ + new Arg($value->items[0]->value), + new Arg($value->items[1]->value), + ]), + new ConstantBooleanType(true), + $context, + $scope, + ); + + return $methodExistsTypes->unionWith($isCallableMarker); } return $this->typeSpecifier->create($value, new CallableType(), $context, $scope); diff --git a/tests/PHPStan/Analyser/nsrt/bug-4510.php b/tests/PHPStan/Analyser/nsrt/bug-4510.php index daaf03a2c3d..a2590e52734 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4510.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4510.php @@ -15,7 +15,7 @@ function testMethodExists(string $method): void { return; } - assertType('list{Bug4510Nsrt\Foo, string}&callable(): mixed', [$instance, $method]); + assertType('array{Bug4510Nsrt\Foo, string}', [$instance, $method]); } function testIsCallableInlineArray(string $method): void { @@ -32,6 +32,14 @@ function testMethodExistsWithClassString(string $method): void { return; } + assertType("array{'Bug4510Nsrt\\\\Foo', string}", [Foo::class, $method]); +} + +function testIsCallableWithClassString(string $method): void { + if (!is_callable([Foo::class, $method])) { + return; + } + assertType("list{'Bug4510Nsrt\\\\Foo', string}&callable(): mixed", [Foo::class, $method]); } diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index 1dc925ca177..8660ceafb65 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -376,7 +376,24 @@ public function testBug4608(): void public function testBug4510(): void { - $this->analyse([__DIR__ . '/data/bug-4510.php'], []); + $this->analyse([__DIR__ . '/data/bug-4510.php'], [ + [ + 'Trying to invoke array{$this(Bug4510\HelloWorld), string} but it might not be a callable.', + 12, + ], + [ + "Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.", + 22, + ], + [ + "Trying to invoke array{'Bug4510\\\HelloWorld', string} but it might not be a callable.", + 39, + ], + [ + "Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.", + 55, + ], + ]); } public function testMaybeNotCallable(): void diff --git a/tests/PHPStan/Rules/Functions/data/bug-4510.php b/tests/PHPStan/Rules/Functions/data/bug-4510.php index ec41d9f41c1..501ac3ed5d8 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-4510.php +++ b/tests/PHPStan/Rules/Functions/data/bug-4510.php @@ -9,7 +9,7 @@ public function doSomething(string $method): void { return; } - [$this, $method](); + [$this, $method](); // error - method_exists doesn't imply callable } } @@ -19,7 +19,7 @@ function bar(string $method): void { return; } - [$instance, $method](); + [$instance, $method](); // error - method_exists doesn't imply callable } function baz(string $method): void { @@ -28,7 +28,7 @@ function baz(string $method): void { return; } - [$instance, $method](); + [$instance, $method](); // ok - is_callable verifies callability } function withClassString(string $method): void { @@ -36,7 +36,7 @@ function withClassString(string $method): void { return; } - [HelloWorld::class, $method](); + [HelloWorld::class, $method](); // error - method_exists doesn't imply callable } function withDynamicMethodExistsAndVariable(string $method): void { @@ -46,12 +46,36 @@ function withDynamicMethodExistsAndVariable(string $method): void { return; } - $callable(); + $callable(); // ok - is_callable on variable already worked } function methodExistsInElseBranch(string $method): void { $instance = new HelloWorld(); if (method_exists($instance, $method)) { - [$instance, $method](); + [$instance, $method](); // error - method_exists doesn't imply callable } } + +function isCallableInElseBranch(string $method): void { + $instance = new HelloWorld(); + if (is_callable([$instance, $method])) { + [$instance, $method](); // ok - is_callable verifies callability + } +} + +function isCallableWithClassString(string $method): void { + if (!is_callable([HelloWorld::class, $method])) { + return; + } + + [HelloWorld::class, $method](); // ok - is_callable verifies callability +} + +function isCallableWithThis(string $method): void { + $instance = new HelloWorld(); + if (!is_callable([$instance, $method])) { + return; + } + + [$instance, $method](); // ok - is_callable verifies callability +} From 94806062211c54cf08705baf2ac5819cb2baa5a9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 27 Apr 2026 09:28:45 +0000 Subject: [PATCH 04/11] Limit is_callable scope check to exactly 2-element arrays 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 --- src/Analyser/ExprHandler/ArrayHandler.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 49c386df5cb..43ade82ac86 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -23,6 +23,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_merge; +use function count; /** * @implements ExprHandler @@ -47,7 +48,8 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type $type = $this->initializerExprTypeResolver->getArrayType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); if ( - $type->isCallable()->maybe() + count($expr->items) === 2 + && $type->isCallable()->maybe() && isset($expr->items[0], $expr->items[1]) ) { $isCallableCall = new FuncCall( From 8f297f08b62804e3c7ac43bfa04462084538a201 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 27 Apr 2026 16:18:19 +0000 Subject: [PATCH 05/11] Reorder conditions for performance and check array item keys; inline 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 --- src/Analyser/ExprHandler/ArrayHandler.php | 4 +++- src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 43ade82ac86..d67ad06e78b 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -49,8 +49,10 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type if ( count($expr->items) === 2 - && $type->isCallable()->maybe() && isset($expr->items[0], $expr->items[1]) + && $expr->items[0]->key === null + && $expr->items[1]->key === null + && $type->isCallable()->maybe() ) { $isCallableCall = new FuncCall( new FullyQualified('is_callable'), diff --git a/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php b/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php index 42b8d5b7e80..fd9ee6edd6f 100644 --- a/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php @@ -61,7 +61,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n ]); $methodExistsTypes = $this->methodExistsExtension->specifyTypes($functionReflection, $functionCall, $scope, $context); - $isCallableMarker = $this->typeSpecifier->create( + return $methodExistsTypes->unionWith($this->typeSpecifier->create( new FuncCall(new FullyQualified('is_callable'), [ new Arg($value->items[0]->value), new Arg($value->items[1]->value), @@ -69,9 +69,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n new ConstantBooleanType(true), $context, $scope, - ); - - return $methodExistsTypes->unionWith($isCallableMarker); + )); } return $this->typeSpecifier->create($value, new CallableType(), $context, $scope); From 766c8207618f989d9b5e7f378696f9859478269e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 27 Apr 2026 16:32:40 +0000 Subject: [PATCH 06/11] Support explicit [0 => $obj, 1 => $method] keys in is_callable scope 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 --- src/Analyser/ExprHandler/ArrayHandler.php | 2 -- tests/PHPStan/Analyser/nsrt/bug-4510.php | 9 +++++++++ tests/PHPStan/Rules/Functions/data/bug-4510.php | 9 +++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index d67ad06e78b..9b60fb0fe63 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -50,8 +50,6 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type if ( count($expr->items) === 2 && isset($expr->items[0], $expr->items[1]) - && $expr->items[0]->key === null - && $expr->items[1]->key === null && $type->isCallable()->maybe() ) { $isCallableCall = new FuncCall( diff --git a/tests/PHPStan/Analyser/nsrt/bug-4510.php b/tests/PHPStan/Analyser/nsrt/bug-4510.php index a2590e52734..7b3164beacc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4510.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4510.php @@ -43,6 +43,15 @@ function testIsCallableWithClassString(string $method): void { assertType("list{'Bug4510Nsrt\\\\Foo', string}&callable(): mixed", [Foo::class, $method]); } +function testIsCallableExplicitKeys(string $method): void { + $instance = new Foo(); + if (!is_callable([0 => $instance, 1 => $method])) { + return; + } + + assertType('list{Bug4510Nsrt\Foo, string}&callable(): mixed', [0 => $instance, 1 => $method]); +} + function testNoMethodExists(string $method): void { $instance = new Foo(); assertType('array{Bug4510Nsrt\Foo, string}', [$instance, $method]); diff --git a/tests/PHPStan/Rules/Functions/data/bug-4510.php b/tests/PHPStan/Rules/Functions/data/bug-4510.php index 501ac3ed5d8..77faaa06993 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-4510.php +++ b/tests/PHPStan/Rules/Functions/data/bug-4510.php @@ -79,3 +79,12 @@ function isCallableWithThis(string $method): void { [$instance, $method](); // ok - is_callable verifies callability } + +function isCallableWithExplicitKeys(string $method): void { + $instance = new HelloWorld(); + if (!is_callable([0 => $instance, 1 => $method])) { + return; + } + + [0 => $instance, 1 => $method](); // ok - is_callable verifies callability +} From 085f943bceb8b6353b190d07fe15bf78f9d5754c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 27 Apr 2026 16:44:54 +0000 Subject: [PATCH 07/11] Merge bug-4510 test files into single nsrt file used by both NSRT and rule tests Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-4510.php | 74 ++++++++++++--- .../Rules/Functions/CallCallablesRuleTest.php | 10 +-- .../PHPStan/Rules/Functions/data/bug-4510.php | 90 ------------------- 3 files changed, 65 insertions(+), 109 deletions(-) delete mode 100644 tests/PHPStan/Rules/Functions/data/bug-4510.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-4510.php b/tests/PHPStan/Analyser/nsrt/bug-4510.php index 7b3164beacc..3074c318cce 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4510.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4510.php @@ -1,58 +1,104 @@ $instance, 1 => $method])) { return; } - assertType('list{Bug4510Nsrt\Foo, string}&callable(): mixed', [0 => $instance, 1 => $method]); + assertType('list{Bug4510\HelloWorld, string}&callable(): mixed', [0 => $instance, 1 => $method]); + [0 => $instance, 1 => $method](); // ok - is_callable verifies callability +} + +function testIsCallableExplicitKeysWithClassString(string $method): void { + if (!is_callable([0 => HelloWorld::class, 1 => $method])) { + return; + } + + assertType("list{'Bug4510\\\\HelloWorld', string}&callable(): mixed", [0 => HelloWorld::class, 1 => $method]); + [0 => HelloWorld::class, 1 => $method](); // ok - is_callable verifies callability +} + +function testWithDynamicMethodExistsAndVariable(string $method): void { + $instance = new HelloWorld(); + $callable = [$instance, $method]; + if (!is_callable($callable)) { + return; + } + + $callable(); // ok - is_callable on variable already worked +} + +function testMethodExistsInElseBranch(string $method): void { + $instance = new HelloWorld(); + if (method_exists($instance, $method)) { + [$instance, $method](); // error - method_exists doesn't imply callable + } +} + +function testIsCallableInElseBranch(string $method): void { + $instance = new HelloWorld(); + if (is_callable([$instance, $method])) { + [$instance, $method](); // ok - is_callable verifies callability + } } function testNoMethodExists(string $method): void { - $instance = new Foo(); - assertType('array{Bug4510Nsrt\Foo, string}', [$instance, $method]); + $instance = new HelloWorld(); + assertType('array{Bug4510\HelloWorld, string}', [$instance, $method]); } diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index 8660ceafb65..c7c6c78d9b1 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -376,22 +376,22 @@ public function testBug4608(): void public function testBug4510(): void { - $this->analyse([__DIR__ . '/data/bug-4510.php'], [ + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-4510.php'], [ [ 'Trying to invoke array{$this(Bug4510\HelloWorld), string} but it might not be a callable.', - 12, + 16, ], [ "Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.", - 22, + 27, ], [ "Trying to invoke array{'Bug4510\\\HelloWorld', string} but it might not be a callable.", - 39, + 46, ], [ "Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.", - 55, + 90, ], ]); } diff --git a/tests/PHPStan/Rules/Functions/data/bug-4510.php b/tests/PHPStan/Rules/Functions/data/bug-4510.php deleted file mode 100644 index 77faaa06993..00000000000 --- a/tests/PHPStan/Rules/Functions/data/bug-4510.php +++ /dev/null @@ -1,90 +0,0 @@ - $instance, 1 => $method])) { - return; - } - - [0 => $instance, 1 => $method](); // ok - is_callable verifies callability -} From f4e3d82d386d2dffcffbfb57d3bf132318e8a47f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 27 Apr 2026 17:30:51 +0000 Subject: [PATCH 08/11] Use single quotes for strings without special escape sequences in CallCallablesRuleTest Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index c7c6c78d9b1..9698df72c97 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -382,7 +382,7 @@ public function testBug4510(): void 16, ], [ - "Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.", + 'Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.', 27, ], [ @@ -390,7 +390,7 @@ public function testBug4510(): void 46, ], [ - "Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.", + 'Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.', 90, ], ]); From 2dec7c67448a5395e66e77f4d8446508dca414e7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 28 Apr 2026 05:09:33 +0000 Subject: [PATCH 09/11] Add test coverage for is_callable and method_exists with named arguments Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-4510.php | 20 +++++++++++++++++++ .../Rules/Functions/CallCallablesRuleTest.php | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-4510.php b/tests/PHPStan/Analyser/nsrt/bug-4510.php index 3074c318cce..ef480d92e96 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4510.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4510.php @@ -98,6 +98,26 @@ function testIsCallableInElseBranch(string $method): void { } } +function testIsCallableNamedArg(string $method): void { + $instance = new HelloWorld(); + if (!is_callable(value: [$instance, $method])) { + return; + } + + assertType('list{Bug4510\HelloWorld, string}&callable(): mixed', [$instance, $method]); + [$instance, $method](); // ok - is_callable verifies callability +} + +function testMethodExistsNamedArgs(string $method): void { + $instance = new HelloWorld(); + if (!method_exists(object_or_class: $instance, method: $method)) { + return; + } + + assertType('array{Bug4510\HelloWorld, string}', [$instance, $method]); + [$instance, $method](); // error - method_exists doesn't imply callable +} + function testNoMethodExists(string $method): void { $instance = new HelloWorld(); assertType('array{Bug4510\HelloWorld, string}', [$instance, $method]); diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index 9698df72c97..5f6b2206702 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -393,6 +393,10 @@ public function testBug4510(): void 'Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.', 90, ], + [ + 'Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.', + 118, + ], ]); } From cffe542d24e05ae3b72bbfde3bf961a139bca08a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 28 Apr 2026 05:44:31 +0000 Subject: [PATCH 10/11] Simplify ArrayHandler callable check to use only hasExpressionType The `isSuperTypeOf` check on `getType()` was redundant: the synthetic `is_callable()` FuncCall resolves to NeverType (due to intersection with the original unresolvable expression type), and NeverType is a subtype of everything, making the check always pass when hasExpressionType is true. The `isTrue()` alternative suggested in review also doesn't work with NeverType. Remove the unnecessary second condition entirely. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/ArrayHandler.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 9b60fb0fe63..02695e67ff7 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -19,7 +19,6 @@ use PHPStan\Node\LiteralArrayNode; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\CallableType; -use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_merge; @@ -56,10 +55,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type new FullyQualified('is_callable'), [new Arg($expr->items[0]->value), new Arg($expr->items[1]->value)], ); - if ( - $scope->hasExpressionType($isCallableCall)->yes() - && (new ConstantBooleanType(true))->isSuperTypeOf($scope->getType($isCallableCall))->yes() - ) { + if ($scope->hasExpressionType($isCallableCall)->yes()) { $type = TypeCombinator::intersect($type, new CallableType()); } } From 811afa806e7999dcfb9124d715decebf4fc80950 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 28 Apr 2026 08:18:29 +0000 Subject: [PATCH 11/11] Fix is_callable marker to use whole array arg, check isTrue() in ArrayHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synthetic is_callable FuncCall used 2 separate args (obj, method), which caused the conditional return type `($value is callable ? true : false)` to evaluate $obj alone as non-callable, returning false. Then addTypeToExpression intersected true ∩ false = NeverType. By passing the whole array as a single arg, the conditional return type evaluates the array's isCallable() which returns maybe → bool, so intersect(true, bool) = true. This allows getType()->isTrue()->yes() to work correctly in ArrayHandler. Added false-branch test to verify [$obj, $method]() is not treated as callable when is_callable([$obj, $method]) returned false. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/ArrayHandler.php | 7 +++++-- .../Php/IsCallableFunctionTypeSpecifyingExtension.php | 3 +-- tests/PHPStan/Analyser/nsrt/bug-4510.php | 10 ++++++++++ .../PHPStan/Rules/Functions/CallCallablesRuleTest.php | 4 ++++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 02695e67ff7..ba1ec13c191 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -53,9 +53,12 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type ) { $isCallableCall = new FuncCall( new FullyQualified('is_callable'), - [new Arg($expr->items[0]->value), new Arg($expr->items[1]->value)], + [new Arg($expr)], ); - if ($scope->hasExpressionType($isCallableCall)->yes()) { + if ( + $scope->hasExpressionType($isCallableCall)->yes() + && $scope->getType($isCallableCall)->isTrue()->yes() + ) { $type = TypeCombinator::intersect($type, new CallableType()); } } diff --git a/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php b/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php index fd9ee6edd6f..c9fe9f343ec 100644 --- a/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/IsCallableFunctionTypeSpecifyingExtension.php @@ -63,8 +63,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n return $methodExistsTypes->unionWith($this->typeSpecifier->create( new FuncCall(new FullyQualified('is_callable'), [ - new Arg($value->items[0]->value), - new Arg($value->items[1]->value), + new Arg($value), ]), new ConstantBooleanType(true), $context, diff --git a/tests/PHPStan/Analyser/nsrt/bug-4510.php b/tests/PHPStan/Analyser/nsrt/bug-4510.php index ef480d92e96..3c5b2c52f70 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4510.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4510.php @@ -122,3 +122,13 @@ function testNoMethodExists(string $method): void { $instance = new HelloWorld(); assertType('array{Bug4510\HelloWorld, string}', [$instance, $method]); } + +function testIsCallableFalseBranch(string $method): void { + $instance = new HelloWorld(); + if (is_callable([$instance, $method])) { + return; + } + + assertType('array{Bug4510\HelloWorld, string}', [$instance, $method]); + [$instance, $method](); // error - is_callable was false +} diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index 5f6b2206702..11ec549385f 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -397,6 +397,10 @@ public function testBug4510(): void 'Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.', 118, ], + [ + 'Trying to invoke array{Bug4510\HelloWorld, string} but it might not be a callable.', + 133, + ], ]); }