From 5bce55b3d3c385fdad001640e0074872051c8315 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sat, 16 May 2026 12:17:20 +0000 Subject: [PATCH 1/8] Include `null` in return type of `Closure::bindTo()` and `Closure::bind()` dynamic return type extensions - ClosureBindToDynamicReturnTypeExtension now returns TypeCombinator::union($closureType, new NullType()) instead of just $closureType - ClosureBindDynamicReturnTypeExtension (for static Closure::bind()) gets the same fix - Updated existing tests that asserted the buggy non-nullable return type - Updated AnalyserIntegrationTest::testBug4734 to expect the 4 new "Trying to invoke ... null" errors Closes https://github.com/phpstan/phpstan/issues/5009 --- .../ClosureBindDynamicReturnTypeExtension.php | 4 ++- ...losureBindToDynamicReturnTypeExtension.php | 4 ++- .../Analyser/AnalyserIntegrationTest.php | 10 +++++--- tests/PHPStan/Analyser/nsrt/bug-5009.php | 25 +++++++++++++++++++ .../nsrt/closure-return-type-extensions.php | 6 ++--- 5 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-5009.php diff --git a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php index 580595acd74..60b7332be49 100644 --- a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php @@ -9,7 +9,9 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Type\ClosureType; use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; +use PHPStan\Type\NullType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; #[AutowiredService] final class ClosureBindDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension @@ -32,7 +34,7 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, return null; } - return $closureType; + return TypeCombinator::union($closureType, new NullType()); } } diff --git a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php index 3e275677e27..f343e097dba 100644 --- a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php @@ -9,7 +9,9 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Type\ClosureType; use PHPStan\Type\DynamicMethodReturnTypeExtension; +use PHPStan\Type\NullType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; #[AutowiredService] final class ClosureBindToDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension @@ -32,7 +34,7 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } - return $closureType; + return TypeCombinator::union($closureType, new NullType()); } } diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 418b57601d2..78368fa0526 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -473,13 +473,17 @@ public function testBug4734(): void { // false positive $errors = $this->runAnalyse(__DIR__ . '/data/bug-4734.php'); - $this->assertCount(5, $errors); // could be 3 + $this->assertCount(9, $errors); // could be 3 $this->assertSame('Static property Bug4734\Foo::$httpMethodParameterOverride (bool) is never assigned false so the property type can be changed to true.', $errors[0]->getMessage()); // should not error $this->assertSame('Property Bug4734\Foo::$httpMethodParameterOverride2 (bool) is never assigned false so the property type can be changed to true.', $errors[1]->getMessage()); // should not error $this->assertSame('Unsafe access to private property Bug4734\Foo::$httpMethodParameterOverride through static::.', $errors[2]->getMessage()); - $this->assertSame('Access to an undefined static property static(Bug4734\Foo)::$httpMethodParameterOverride3.', $errors[3]->getMessage()); - $this->assertSame('Access to an undefined property Bug4734\Foo::$httpMethodParameterOverride4.', $errors[4]->getMessage()); + $this->assertSame('Trying to invoke (Closure(): void)|null but it might not be a callable.', $errors[3]->getMessage()); + $this->assertSame('Trying to invoke (Closure(): void)|null but it might not be a callable.', $errors[4]->getMessage()); + $this->assertSame('Access to an undefined static property static(Bug4734\Foo)::$httpMethodParameterOverride3.', $errors[5]->getMessage()); + $this->assertSame('Trying to invoke (Closure(): void)|null but it might not be a callable.', $errors[6]->getMessage()); + $this->assertSame('Access to an undefined property Bug4734\Foo::$httpMethodParameterOverride4.', $errors[7]->getMessage()); + $this->assertSame('Trying to invoke (Closure(): void)|null but it might not be a callable.', $errors[8]->getMessage()); } #[RequiresPhp('>= 8.1.0')] diff --git a/tests/PHPStan/Analyser/nsrt/bug-5009.php b/tests/PHPStan/Analyser/nsrt/bug-5009.php new file mode 100644 index 00000000000..85c07b1719d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-5009.php @@ -0,0 +1,25 @@ +bindTo(null); +assertType('(Closure(): void)|null', $bar); + +$baz = Closure::bind($foo, null); +assertType('(Closure(): void)|null', $baz); + +$newThis = new \stdClass(); +$bound = $foo->bindTo($newThis); +assertType('(Closure(): void)|null', $bound); + +$staticBound = Closure::bind($foo, $newThis); +assertType('(Closure(): void)|null', $staticBound); + +$bound2 = $foo->bindTo($newThis, 'stdClass'); +assertType('(Closure(): void)|null', $bound2); diff --git a/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php b/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php index 1445bfad9f7..df94e0ee17d 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php +++ b/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php @@ -11,13 +11,13 @@ $newThis = new class {}; $boundClosure = $closure->bindTo($newThis); -assertType('Closure(object): true', $boundClosure); +assertType('(Closure(object): true)|null', $boundClosure); $staticallyBoundClosure = \Closure::bind($closure, $newThis); -assertType('Closure(object): true', $staticallyBoundClosure); +assertType('(Closure(object): true)|null', $staticallyBoundClosure); $returnType = $closure->call($newThis, new class {}); assertType('true', $returnType); $staticallyBoundClosureCaseInsensitive = \closure::bind($closure, $newThis); -assertType('Closure(object): true', $staticallyBoundClosureCaseInsensitive); +assertType('(Closure(object): true)|null', $staticallyBoundClosureCaseInsensitive); From afbe6f552f5459712b3cdf071b914fac15597435 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 16 May 2026 12:38:10 +0000 Subject: [PATCH 2/8] Use BenevolentUnionType for Closure::bindTo() and Closure::bind() return types The function signature map declares these as `__benevolent`, so the dynamic return type extensions should preserve the benevolent union semantics instead of creating a strict union. This avoids false positives for code that invokes the result without null-checking, matching the behavior of the default signature map. Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/ClosureBindDynamicReturnTypeExtension.php | 4 ++-- .../Php/ClosureBindToDynamicReturnTypeExtension.php | 4 ++-- tests/PHPStan/Analyser/AnalyserIntegrationTest.php | 10 +++------- tests/PHPStan/Analyser/nsrt/bug-5009.php | 10 +++++----- .../Analyser/nsrt/closure-return-type-extensions.php | 6 +++--- 5 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php index 60b7332be49..1e00c2e08ac 100644 --- a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php @@ -7,11 +7,11 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\MethodReflection; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\ClosureType; use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; use PHPStan\Type\NullType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; #[AutowiredService] final class ClosureBindDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension @@ -34,7 +34,7 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, return null; } - return TypeCombinator::union($closureType, new NullType()); + return new BenevolentUnionType([$closureType, new NullType()]); } } diff --git a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php index f343e097dba..51993559a05 100644 --- a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php @@ -7,11 +7,11 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\MethodReflection; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\ClosureType; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\NullType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; #[AutowiredService] final class ClosureBindToDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension @@ -34,7 +34,7 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } - return TypeCombinator::union($closureType, new NullType()); + return new BenevolentUnionType([$closureType, new NullType()]); } } diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 78368fa0526..418b57601d2 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -473,17 +473,13 @@ public function testBug4734(): void { // false positive $errors = $this->runAnalyse(__DIR__ . '/data/bug-4734.php'); - $this->assertCount(9, $errors); // could be 3 + $this->assertCount(5, $errors); // could be 3 $this->assertSame('Static property Bug4734\Foo::$httpMethodParameterOverride (bool) is never assigned false so the property type can be changed to true.', $errors[0]->getMessage()); // should not error $this->assertSame('Property Bug4734\Foo::$httpMethodParameterOverride2 (bool) is never assigned false so the property type can be changed to true.', $errors[1]->getMessage()); // should not error $this->assertSame('Unsafe access to private property Bug4734\Foo::$httpMethodParameterOverride through static::.', $errors[2]->getMessage()); - $this->assertSame('Trying to invoke (Closure(): void)|null but it might not be a callable.', $errors[3]->getMessage()); - $this->assertSame('Trying to invoke (Closure(): void)|null but it might not be a callable.', $errors[4]->getMessage()); - $this->assertSame('Access to an undefined static property static(Bug4734\Foo)::$httpMethodParameterOverride3.', $errors[5]->getMessage()); - $this->assertSame('Trying to invoke (Closure(): void)|null but it might not be a callable.', $errors[6]->getMessage()); - $this->assertSame('Access to an undefined property Bug4734\Foo::$httpMethodParameterOverride4.', $errors[7]->getMessage()); - $this->assertSame('Trying to invoke (Closure(): void)|null but it might not be a callable.', $errors[8]->getMessage()); + $this->assertSame('Access to an undefined static property static(Bug4734\Foo)::$httpMethodParameterOverride3.', $errors[3]->getMessage()); + $this->assertSame('Access to an undefined property Bug4734\Foo::$httpMethodParameterOverride4.', $errors[4]->getMessage()); } #[RequiresPhp('>= 8.1.0')] diff --git a/tests/PHPStan/Analyser/nsrt/bug-5009.php b/tests/PHPStan/Analyser/nsrt/bug-5009.php index 85c07b1719d..a9160d4f6f5 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5009.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5009.php @@ -9,17 +9,17 @@ $foo = function (): void {}; $bar = $foo->bindTo(null); -assertType('(Closure(): void)|null', $bar); +assertType('((Closure(): void)|null)', $bar); $baz = Closure::bind($foo, null); -assertType('(Closure(): void)|null', $baz); +assertType('((Closure(): void)|null)', $baz); $newThis = new \stdClass(); $bound = $foo->bindTo($newThis); -assertType('(Closure(): void)|null', $bound); +assertType('((Closure(): void)|null)', $bound); $staticBound = Closure::bind($foo, $newThis); -assertType('(Closure(): void)|null', $staticBound); +assertType('((Closure(): void)|null)', $staticBound); $bound2 = $foo->bindTo($newThis, 'stdClass'); -assertType('(Closure(): void)|null', $bound2); +assertType('((Closure(): void)|null)', $bound2); diff --git a/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php b/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php index df94e0ee17d..eb21b2fb97d 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php +++ b/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php @@ -11,13 +11,13 @@ $newThis = new class {}; $boundClosure = $closure->bindTo($newThis); -assertType('(Closure(object): true)|null', $boundClosure); +assertType('((Closure(object): true)|null)', $boundClosure); $staticallyBoundClosure = \Closure::bind($closure, $newThis); -assertType('(Closure(object): true)|null', $staticallyBoundClosure); +assertType('((Closure(object): true)|null)', $staticallyBoundClosure); $returnType = $closure->call($newThis, new class {}); assertType('true', $returnType); $staticallyBoundClosureCaseInsensitive = \closure::bind($closure, $newThis); -assertType('(Closure(object): true)|null', $staticallyBoundClosureCaseInsensitive); +assertType('((Closure(object): true)|null)', $staticallyBoundClosureCaseInsensitive); From 3c6b6c04fb9ce5e0d4f6b26b07318fb83fd87851 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 16 May 2026 13:03:48 +0000 Subject: [PATCH 3/8] Exclude null from Closure::bindTo()/bind() return type when $newThis is null When $newThis is null, bindTo()/bind() only changes the closure's scope without binding $this, which always succeeds. Null is only returned when binding a static closure with a non-null $newThis. Co-Authored-By: Claude Opus 4.6 --- .../ClosureBindDynamicReturnTypeExtension.php | 7 ++++++- .../ClosureBindToDynamicReturnTypeExtension.php | 5 +++++ tests/PHPStan/Analyser/nsrt/bug-5009.php | 16 ++++++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php index 1e00c2e08ac..7a83c983935 100644 --- a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php @@ -29,11 +29,16 @@ public function isStaticMethodSupported(MethodReflection $methodReflection): boo public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type { - $closureType = $scope->getType($methodCall->getArgs()[0]->value); + $args = $methodCall->getArgs(); + $closureType = $scope->getType($args[0]->value); if (!($closureType instanceof ClosureType)) { return null; } + if (isset($args[1]) && $scope->getType($args[1]->value)->isNull()->yes()) { + return $closureType; + } + return new BenevolentUnionType([$closureType, new NullType()]); } diff --git a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php index 51993559a05..089745099cf 100644 --- a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php @@ -34,6 +34,11 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } + $args = $methodCall->getArgs(); + if (isset($args[0]) && $scope->getType($args[0]->value)->isNull()->yes()) { + return $closureType; + } + return new BenevolentUnionType([$closureType, new NullType()]); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-5009.php b/tests/PHPStan/Analyser/nsrt/bug-5009.php index a9160d4f6f5..7e64dcc0632 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5009.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5009.php @@ -9,10 +9,10 @@ $foo = function (): void {}; $bar = $foo->bindTo(null); -assertType('((Closure(): void)|null)', $bar); +assertType('Closure(): void', $bar); $baz = Closure::bind($foo, null); -assertType('((Closure(): void)|null)', $baz); +assertType('Closure(): void', $baz); $newThis = new \stdClass(); $bound = $foo->bindTo($newThis); @@ -23,3 +23,15 @@ $bound2 = $foo->bindTo($newThis, 'stdClass'); assertType('((Closure(): void)|null)', $bound2); + +$static = static function (): void {}; +$boundStatic = $static->bindTo($newThis); +assertType('((Closure(): void)|null)', $boundStatic); + +$boundStaticNull = $static->bindTo(null); +assertType('Closure(): void', $boundStaticNull); + +/** @var \stdClass|null $maybeNull */ +$maybeNull = null; +$boundMaybe = $foo->bindTo($maybeNull); +assertType('((Closure(): void)|null)', $boundMaybe); From 534f083e61a72b637715127bf3c35c5ce60e0553 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 16 May 2026 13:19:28 +0000 Subject: [PATCH 4/8] Track static-ness of closures in ClosureType to exclude null from bindTo()/bind() return type Add an optional `?TrinaryLogic $isStatic` parameter to ClosureType's constructor to track whether a closure was declared with the `static` keyword. ClosureTypeResolver now passes this from the AST node. The bind extensions use this to exclude null from the return type when the closure is known to be non-static (binding always succeeds for non-static closures regardless of $newThis). For static or possibly-static closures with non-null $newThis, the return type remains BenevolentUnionType(Closure|null) since PHP returns null when binding an object to a static closure. Co-Authored-By: Claude Opus 4.6 --- .../ClosureBindDynamicReturnTypeExtension.php | 4 ++++ .../ClosureBindToDynamicReturnTypeExtension.php | 4 ++++ tests/PHPStan/Analyser/nsrt/bug-5009.php | 17 +++++++++++++---- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php index 7a83c983935..8b0908dadfb 100644 --- a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php @@ -35,6 +35,10 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, return null; } + if ($closureType->isStaticClosure()->no()) { + return $closureType; + } + if (isset($args[1]) && $scope->getType($args[1]->value)->isNull()->yes()) { return $closureType; } diff --git a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php index 089745099cf..7423c73198d 100644 --- a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php @@ -34,6 +34,10 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } + if ($closureType->isStaticClosure()->no()) { + return $closureType; + } + $args = $methodCall->getArgs(); if (isset($args[0]) && $scope->getType($args[0]->value)->isNull()->yes()) { return $closureType; diff --git a/tests/PHPStan/Analyser/nsrt/bug-5009.php b/tests/PHPStan/Analyser/nsrt/bug-5009.php index 7e64dcc0632..691af4c4646 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5009.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5009.php @@ -16,13 +16,13 @@ $newThis = new \stdClass(); $bound = $foo->bindTo($newThis); -assertType('((Closure(): void)|null)', $bound); +assertType('Closure(): void', $bound); $staticBound = Closure::bind($foo, $newThis); -assertType('((Closure(): void)|null)', $staticBound); +assertType('Closure(): void', $staticBound); $bound2 = $foo->bindTo($newThis, 'stdClass'); -assertType('((Closure(): void)|null)', $bound2); +assertType('Closure(): void', $bound2); $static = static function (): void {}; $boundStatic = $static->bindTo($newThis); @@ -31,7 +31,16 @@ $boundStaticNull = $static->bindTo(null); assertType('Closure(): void', $boundStaticNull); +$staticBound2 = Closure::bind($static, $newThis); +assertType('((Closure(): void)|null)', $staticBound2); + +$staticBoundNull = Closure::bind($static, null); +assertType('Closure(): void', $staticBoundNull); + /** @var \stdClass|null $maybeNull */ $maybeNull = null; $boundMaybe = $foo->bindTo($maybeNull); -assertType('((Closure(): void)|null)', $boundMaybe); +assertType('Closure(): void', $boundMaybe); + +$staticBoundMaybe = $static->bindTo($maybeNull); +assertType('((Closure(): void)|null)', $staticBoundMaybe); From c8e24bbd1bf8a7649b2ebe6b08ea75a26ec068d7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 12 Jun 2026 14:59:49 +0000 Subject: [PATCH 5/8] Update closure binding test expectations for static-closure null tracking Reflect the corrected dynamic return types of Closure::bindTo()/bind(): non-static closures bind successfully (no null), while static and unknown-staticness closures bound to a non-null scope can fail and yield ((...)|null). Static closures also describe with the static- prefix. Co-Authored-By: Claude Opus 4.8 --- tests/PHPStan/Analyser/nsrt/bug-5009.php | 10 +++++----- .../Analyser/nsrt/closure-return-type-extensions.php | 6 +++--- tests/PHPStan/Analyser/nsrt/closure-static-type.php | 10 +++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-5009.php b/tests/PHPStan/Analyser/nsrt/bug-5009.php index 691af4c4646..80bfb7cbea3 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5009.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5009.php @@ -26,16 +26,16 @@ $static = static function (): void {}; $boundStatic = $static->bindTo($newThis); -assertType('((Closure(): void)|null)', $boundStatic); +assertType('((static-Closure(): void)|null)', $boundStatic); $boundStaticNull = $static->bindTo(null); -assertType('Closure(): void', $boundStaticNull); +assertType('static-Closure(): void', $boundStaticNull); $staticBound2 = Closure::bind($static, $newThis); -assertType('((Closure(): void)|null)', $staticBound2); +assertType('((static-Closure(): void)|null)', $staticBound2); $staticBoundNull = Closure::bind($static, null); -assertType('Closure(): void', $staticBoundNull); +assertType('static-Closure(): void', $staticBoundNull); /** @var \stdClass|null $maybeNull */ $maybeNull = null; @@ -43,4 +43,4 @@ assertType('Closure(): void', $boundMaybe); $staticBoundMaybe = $static->bindTo($maybeNull); -assertType('((Closure(): void)|null)', $staticBoundMaybe); +assertType('((static-Closure(): void)|null)', $staticBoundMaybe); diff --git a/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php b/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php index eb21b2fb97d..1445bfad9f7 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php +++ b/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php @@ -11,13 +11,13 @@ $newThis = new class {}; $boundClosure = $closure->bindTo($newThis); -assertType('((Closure(object): true)|null)', $boundClosure); +assertType('Closure(object): true', $boundClosure); $staticallyBoundClosure = \Closure::bind($closure, $newThis); -assertType('((Closure(object): true)|null)', $staticallyBoundClosure); +assertType('Closure(object): true', $staticallyBoundClosure); $returnType = $closure->call($newThis, new class {}); assertType('true', $returnType); $staticallyBoundClosureCaseInsensitive = \closure::bind($closure, $newThis); -assertType('((Closure(object): true)|null)', $staticallyBoundClosureCaseInsensitive); +assertType('Closure(object): true', $staticallyBoundClosureCaseInsensitive); diff --git a/tests/PHPStan/Analyser/nsrt/closure-static-type.php b/tests/PHPStan/Analyser/nsrt/closure-static-type.php index 6b84bd967df..f9846fbb5d0 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-static-type.php +++ b/tests/PHPStan/Analyser/nsrt/closure-static-type.php @@ -26,7 +26,7 @@ public function doFoo(): void public function doBindTo(): void { $static = static function (): void {}; - assertType('static-Closure(): void', $static->bindTo($this)); + assertType('((static-Closure(): void)|null)', $static->bindTo($this)); $nonStatic = function (): void {}; assertType('Closure(): void', $nonStatic->bindTo($this)); @@ -35,7 +35,7 @@ public function doBindTo(): void public function doBind(): void { $static = static function (): void {}; - assertType('static-Closure(): void', Closure::bind($static, $this)); + assertType('((static-Closure(): void)|null)', Closure::bind($static, $this)); $nonStatic = function (): void {}; assertType('Closure(): void', Closure::bind($nonStatic, $this)); @@ -46,14 +46,14 @@ public function doBind(): void */ public function doUnknown(Closure $unknownClosure): void { - assertType('Closure(): void', $unknownClosure->bindTo($this)); - assertType('Closure(): void', Closure::bind($unknownClosure, $this)); + assertType('((Closure(): void)|null)', $unknownClosure->bindTo($this)); + assertType('((Closure(): void)|null)', Closure::bind($unknownClosure, $this)); } public function doFromCallable(): void { $fn = Closure::fromCallable(static function (): void {}); - assertType('static-Closure(): void', $fn->bindTo($this)); + assertType('((static-Closure(): void)|null)', $fn->bindTo($this)); $fn2 = Closure::fromCallable(function (): void {}); assertType('Closure(): void', $fn2->bindTo($this)); From fc903c542a9ded6f92f6cbda4b6afffb9bf2b8c0 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 12 Jun 2026 15:24:07 +0000 Subject: [PATCH 6/8] Restructure Closure::bind()/bindTo() return type: null when static closure binds to object For a static closure bound to a non-null $newThis, binding fails and PHP returns null, so the extension now returns NullType. Static closure bound to null returns the closure unchanged. All other (non-static / unknown-staticness) cases return BenevolentUnionType. Co-Authored-By: Claude Opus 4.8 --- .../ClosureBindDynamicReturnTypeExtension.php | 12 +++++++----- ...ClosureBindToDynamicReturnTypeExtension.php | 10 ++++------ .../Analyser/AnalyserIntegrationTest.php | 7 ++++--- tests/PHPStan/Analyser/nsrt/bug-5009.php | 18 +++++++++--------- .../nsrt/closure-return-type-extensions.php | 6 +++--- .../Analyser/nsrt/closure-static-type.php | 12 ++++++------ 6 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php index 8b0908dadfb..7a4e0d0df8e 100644 --- a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php @@ -30,17 +30,19 @@ public function isStaticMethodSupported(MethodReflection $methodReflection): boo public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type { $args = $methodCall->getArgs(); + if (!isset($args[0])) { + return null; + } + $closureType = $scope->getType($args[0]->value); if (!($closureType instanceof ClosureType)) { return null; } - if ($closureType->isStaticClosure()->no()) { - return $closureType; - } + if ($closureType->isStaticClosure()->yes()) { + $newThisIsNull = !isset($args[1]) || $scope->getType($args[1]->value)->isNull()->yes(); - if (isset($args[1]) && $scope->getType($args[1]->value)->isNull()->yes()) { - return $closureType; + return $newThisIsNull ? $closureType : new NullType(); } return new BenevolentUnionType([$closureType, new NullType()]); diff --git a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php index 7423c73198d..29f2dc0d85b 100644 --- a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php @@ -34,13 +34,11 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } - if ($closureType->isStaticClosure()->no()) { - return $closureType; - } + if ($closureType->isStaticClosure()->yes()) { + $args = $methodCall->getArgs(); + $newThisIsNull = !isset($args[0]) || $scope->getType($args[0]->value)->isNull()->yes(); - $args = $methodCall->getArgs(); - if (isset($args[0]) && $scope->getType($args[0]->value)->isNull()->yes()) { - return $closureType; + return $newThisIsNull ? $closureType : new NullType(); } return new BenevolentUnionType([$closureType, new NullType()]); diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 418b57601d2..d6e9e7e38cd 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -473,13 +473,14 @@ public function testBug4734(): void { // false positive $errors = $this->runAnalyse(__DIR__ . '/data/bug-4734.php'); - $this->assertCount(5, $errors); // could be 3 + $this->assertCount(6, $errors); // could be 4 $this->assertSame('Static property Bug4734\Foo::$httpMethodParameterOverride (bool) is never assigned false so the property type can be changed to true.', $errors[0]->getMessage()); // should not error $this->assertSame('Property Bug4734\Foo::$httpMethodParameterOverride2 (bool) is never assigned false so the property type can be changed to true.', $errors[1]->getMessage()); // should not error $this->assertSame('Unsafe access to private property Bug4734\Foo::$httpMethodParameterOverride through static::.', $errors[2]->getMessage()); - $this->assertSame('Access to an undefined static property static(Bug4734\Foo)::$httpMethodParameterOverride3.', $errors[3]->getMessage()); - $this->assertSame('Access to an undefined property Bug4734\Foo::$httpMethodParameterOverride4.', $errors[4]->getMessage()); + $this->assertSame('Trying to invoke null but it\'s not a callable.', $errors[3]->getMessage()); // binding a static closure to an object returns null + $this->assertSame('Access to an undefined static property static(Bug4734\Foo)::$httpMethodParameterOverride3.', $errors[4]->getMessage()); + $this->assertSame('Access to an undefined property Bug4734\Foo::$httpMethodParameterOverride4.', $errors[5]->getMessage()); } #[RequiresPhp('>= 8.1.0')] diff --git a/tests/PHPStan/Analyser/nsrt/bug-5009.php b/tests/PHPStan/Analyser/nsrt/bug-5009.php index 80bfb7cbea3..5f666eb0c7c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5009.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5009.php @@ -9,30 +9,30 @@ $foo = function (): void {}; $bar = $foo->bindTo(null); -assertType('Closure(): void', $bar); +assertType('((Closure(): void)|null)', $bar); $baz = Closure::bind($foo, null); -assertType('Closure(): void', $baz); +assertType('((Closure(): void)|null)', $baz); $newThis = new \stdClass(); $bound = $foo->bindTo($newThis); -assertType('Closure(): void', $bound); +assertType('((Closure(): void)|null)', $bound); $staticBound = Closure::bind($foo, $newThis); -assertType('Closure(): void', $staticBound); +assertType('((Closure(): void)|null)', $staticBound); $bound2 = $foo->bindTo($newThis, 'stdClass'); -assertType('Closure(): void', $bound2); +assertType('((Closure(): void)|null)', $bound2); $static = static function (): void {}; $boundStatic = $static->bindTo($newThis); -assertType('((static-Closure(): void)|null)', $boundStatic); +assertType('null', $boundStatic); $boundStaticNull = $static->bindTo(null); assertType('static-Closure(): void', $boundStaticNull); $staticBound2 = Closure::bind($static, $newThis); -assertType('((static-Closure(): void)|null)', $staticBound2); +assertType('null', $staticBound2); $staticBoundNull = Closure::bind($static, null); assertType('static-Closure(): void', $staticBoundNull); @@ -40,7 +40,7 @@ /** @var \stdClass|null $maybeNull */ $maybeNull = null; $boundMaybe = $foo->bindTo($maybeNull); -assertType('Closure(): void', $boundMaybe); +assertType('((Closure(): void)|null)', $boundMaybe); $staticBoundMaybe = $static->bindTo($maybeNull); -assertType('((static-Closure(): void)|null)', $staticBoundMaybe); +assertType('null', $staticBoundMaybe); diff --git a/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php b/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php index 1445bfad9f7..eb21b2fb97d 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php +++ b/tests/PHPStan/Analyser/nsrt/closure-return-type-extensions.php @@ -11,13 +11,13 @@ $newThis = new class {}; $boundClosure = $closure->bindTo($newThis); -assertType('Closure(object): true', $boundClosure); +assertType('((Closure(object): true)|null)', $boundClosure); $staticallyBoundClosure = \Closure::bind($closure, $newThis); -assertType('Closure(object): true', $staticallyBoundClosure); +assertType('((Closure(object): true)|null)', $staticallyBoundClosure); $returnType = $closure->call($newThis, new class {}); assertType('true', $returnType); $staticallyBoundClosureCaseInsensitive = \closure::bind($closure, $newThis); -assertType('Closure(object): true', $staticallyBoundClosureCaseInsensitive); +assertType('((Closure(object): true)|null)', $staticallyBoundClosureCaseInsensitive); diff --git a/tests/PHPStan/Analyser/nsrt/closure-static-type.php b/tests/PHPStan/Analyser/nsrt/closure-static-type.php index f9846fbb5d0..2b971fbcccf 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-static-type.php +++ b/tests/PHPStan/Analyser/nsrt/closure-static-type.php @@ -26,19 +26,19 @@ public function doFoo(): void public function doBindTo(): void { $static = static function (): void {}; - assertType('((static-Closure(): void)|null)', $static->bindTo($this)); + assertType('null', $static->bindTo($this)); $nonStatic = function (): void {}; - assertType('Closure(): void', $nonStatic->bindTo($this)); + assertType('((Closure(): void)|null)', $nonStatic->bindTo($this)); } public function doBind(): void { $static = static function (): void {}; - assertType('((static-Closure(): void)|null)', Closure::bind($static, $this)); + assertType('null', Closure::bind($static, $this)); $nonStatic = function (): void {}; - assertType('Closure(): void', Closure::bind($nonStatic, $this)); + assertType('((Closure(): void)|null)', Closure::bind($nonStatic, $this)); } /** @@ -53,10 +53,10 @@ public function doUnknown(Closure $unknownClosure): void public function doFromCallable(): void { $fn = Closure::fromCallable(static function (): void {}); - assertType('((static-Closure(): void)|null)', $fn->bindTo($this)); + assertType('null', $fn->bindTo($this)); $fn2 = Closure::fromCallable(function (): void {}); - assertType('Closure(): void', $fn2->bindTo($this)); + assertType('((Closure(): void)|null)', $fn2->bindTo($this)); } } From ee1cebe7de3b0fefbd6ccaa774e6052efbc14dcf Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 12 Jun 2026 15:32:33 +0000 Subject: [PATCH 7/8] Return benevolent Closure|null when static-closure $newThis is maybe-null Co-Authored-By: Claude Opus 4.8 --- .../Php/ClosureBindDynamicReturnTypeExtension.php | 13 ++++++++++--- .../Php/ClosureBindToDynamicReturnTypeExtension.php | 13 ++++++++++--- tests/PHPStan/Analyser/nsrt/bug-5009.php | 2 +- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php index 7a4e0d0df8e..1234067633d 100644 --- a/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindDynamicReturnTypeExtension.php @@ -7,6 +7,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\MethodReflection; +use PHPStan\TrinaryLogic; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\ClosureType; use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; @@ -40,9 +41,15 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, } if ($closureType->isStaticClosure()->yes()) { - $newThisIsNull = !isset($args[1]) || $scope->getType($args[1]->value)->isNull()->yes(); - - return $newThisIsNull ? $closureType : new NullType(); + $newThisIsNull = isset($args[1]) ? $scope->getType($args[1]->value)->isNull() : TrinaryLogic::createYes(); + if ($newThisIsNull->yes()) { + return $closureType; + } + if ($newThisIsNull->no()) { + return new NullType(); + } + + return new BenevolentUnionType([$closureType, new NullType()]); } return new BenevolentUnionType([$closureType, new NullType()]); diff --git a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php index 29f2dc0d85b..dc9aaf33a6f 100644 --- a/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php +++ b/src/Type/Php/ClosureBindToDynamicReturnTypeExtension.php @@ -7,6 +7,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\MethodReflection; +use PHPStan\TrinaryLogic; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\ClosureType; use PHPStan\Type\DynamicMethodReturnTypeExtension; @@ -36,9 +37,15 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method if ($closureType->isStaticClosure()->yes()) { $args = $methodCall->getArgs(); - $newThisIsNull = !isset($args[0]) || $scope->getType($args[0]->value)->isNull()->yes(); - - return $newThisIsNull ? $closureType : new NullType(); + $newThisIsNull = isset($args[0]) ? $scope->getType($args[0]->value)->isNull() : TrinaryLogic::createYes(); + if ($newThisIsNull->yes()) { + return $closureType; + } + if ($newThisIsNull->no()) { + return new NullType(); + } + + return new BenevolentUnionType([$closureType, new NullType()]); } return new BenevolentUnionType([$closureType, new NullType()]); diff --git a/tests/PHPStan/Analyser/nsrt/bug-5009.php b/tests/PHPStan/Analyser/nsrt/bug-5009.php index 5f666eb0c7c..d72aee74c6f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5009.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5009.php @@ -43,4 +43,4 @@ assertType('((Closure(): void)|null)', $boundMaybe); $staticBoundMaybe = $static->bindTo($maybeNull); -assertType('null', $staticBoundMaybe); +assertType('((static-Closure(): void)|null)', $staticBoundMaybe); From 132bae215ca8d0aecb28435fc10a524ad0392442 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 12 Jun 2026 17:44:04 +0000 Subject: [PATCH 8/8] Add NullCoalesce regression test for nullable Closure::bindTo() result Verifies that binding a closure with ?? no longer reports a false-positive "Expression on left side of ?? is not nullable." error, since bindTo() now returns a benevolent Closure|null. Co-Authored-By: Claude Opus 4.8 --- .../Rules/Variables/NullCoalesceRuleTest.php | 5 +++++ .../PHPStan/Rules/Variables/data/bug-5009.php | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-5009.php diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index b8ed61a1d96..e22565393fe 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -244,6 +244,11 @@ public function testNullCoalesceInGlobalScope(): void ]); } + public function testBug5009(): void + { + $this->analyse([__DIR__ . '/data/bug-5009.php'], []); + } + public function testBug5933(): void { $this->analyse([__DIR__ . '/data/bug-5933.php'], []); diff --git a/tests/PHPStan/Rules/Variables/data/bug-5009.php b/tests/PHPStan/Rules/Variables/data/bug-5009.php new file mode 100644 index 00000000000..a7bf271458f --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-5009.php @@ -0,0 +1,18 @@ +callback = $callback->bindTo($this, $this) ?? $callback; + } +}