From 2bedf83eb8b43625d9fe3ab817858ff5ba1d4fa6 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:37:44 +0000 Subject: [PATCH 01/17] Fix phpstan/phpstan#11073: Nullsafe operator chaining false positive - Fixed DateTimeModifyReturnTypeExtension to strip null from callee type before returning it as the method's return type - The extension was returning $scope->getType($methodCall->var) which includes null from the nullsafe operator, causing "Cannot call method on DateTimeImmutable|null" false positive for chained calls like $date?->modify('+1 year')->setTime(23, 59, 59) - Updated existing date-format.php test assertion (null was incorrectly expected in modify() return type) - Added regression tests for the nullsafe chaining scenario --- .../Php/DateTimeModifyReturnTypeExtension.php | 6 +++++- tests/PHPStan/Analyser/nsrt/bug-11073.php | 16 ++++++++++++++++ tests/PHPStan/Analyser/nsrt/date-format.php | 2 +- .../Rules/Methods/CallMethodsRuleTest.php | 9 +++++++++ tests/PHPStan/Rules/Methods/data/bug-11073.php | 15 +++++++++++++++ 5 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11073.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-11073.php diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php index 0ed2933856d..818f3913103 100644 --- a/src/Type/Php/DateTimeModifyReturnTypeExtension.php +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -77,7 +77,11 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } elseif ($hasDateTime) { - return $scope->getType($methodCall->var); + $callerType = $scope->getType($methodCall->var); + if (TypeCombinator::containsNull($callerType)) { + $callerType = TypeCombinator::removeNull($callerType); + } + return $callerType; } if ($this->phpVersion->hasDateTimeExceptions()) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-11073.php b/tests/PHPStan/Analyser/nsrt/bug-11073.php new file mode 100644 index 00000000000..45328fc0417 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11073.php @@ -0,0 +1,16 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug11073Nsrt; + +use DateTimeImmutable; +use function PHPStan\Testing\assertType; + +class HelloWorld +{ + public function sayHello(?DateTimeImmutable $date): void + { + assertType('DateTimeImmutable|null', $date?->modify('+1 year')->setTime(23, 59, 59)); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/date-format.php b/tests/PHPStan/Analyser/nsrt/date-format.php index e8a68785216..f8185217192 100644 --- a/tests/PHPStan/Analyser/nsrt/date-format.php +++ b/tests/PHPStan/Analyser/nsrt/date-format.php @@ -45,5 +45,5 @@ function (\DateTimeImmutable $dt, string $s): void { }; function (?\DateTimeImmutable $d): void { - assertType('DateTimeImmutable|null', $d->modify('+1 day')); + assertType('DateTimeImmutable', $d->modify('+1 day')); }; diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 686afb3f8be..2bbd2621e0f 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3945,6 +3945,15 @@ public function testBug7369(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testBug11073(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-11073.php'], []); + } + public function testBug11463(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/data/bug-11073.php b/tests/PHPStan/Rules/Methods/data/bug-11073.php new file mode 100644 index 00000000000..1f0d0301ff7 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-11073.php @@ -0,0 +1,15 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug11073; + +use DateTimeImmutable; + +class HelloWorld +{ + public function sayHello(?DateTimeImmutable $date): ?DateTimeImmutable + { + return $date?->modify('+1 year')->setTime(23, 59, 59); + } +} From 20a8328cc5a7f04af05f6cc6797a18b7c1c35f29 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 5 Apr 2026 22:57:25 +0000 Subject: [PATCH 02/17] Remove redundant containsNull check before removeNull TypeCombinator::removeNull() already handles the case where the type does not contain null, making the containsNull guard unnecessary. Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/DateTimeModifyReturnTypeExtension.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php index 818f3913103..a4155a88eb9 100644 --- a/src/Type/Php/DateTimeModifyReturnTypeExtension.php +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -77,11 +77,7 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } elseif ($hasDateTime) { - $callerType = $scope->getType($methodCall->var); - if (TypeCombinator::containsNull($callerType)) { - $callerType = TypeCombinator::removeNull($callerType); - } - return $callerType; + return TypeCombinator::removeNull($scope->getType($methodCall->var)); } if ($this->phpVersion->hasDateTimeExceptions()) { From d7ab184b246b503c88ff8cb847958611accef77f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 5 Apr 2026 23:00:50 +0000 Subject: [PATCH 03/17] Add assertType for nullsafe modify call in date-format test Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/date-format.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/PHPStan/Analyser/nsrt/date-format.php b/tests/PHPStan/Analyser/nsrt/date-format.php index f8185217192..49018ab8efc 100644 --- a/tests/PHPStan/Analyser/nsrt/date-format.php +++ b/tests/PHPStan/Analyser/nsrt/date-format.php @@ -46,4 +46,5 @@ function (\DateTimeImmutable $dt, string $s): void { function (?\DateTimeImmutable $d): void { assertType('DateTimeImmutable', $d->modify('+1 day')); + assertType('DateTimeImmutable|null', $d?->modify('+1 day')); }; From 97f02941fcbf3bbb821b65bcd55560d3dacc2e8e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 07:37:13 +0000 Subject: [PATCH 04/17] Add assertType for modify() on union DateTimeImmutable subclasses Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/date-format.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/date-format.php b/tests/PHPStan/Analyser/nsrt/date-format.php index 49018ab8efc..05876211568 100644 --- a/tests/PHPStan/Analyser/nsrt/date-format.php +++ b/tests/PHPStan/Analyser/nsrt/date-format.php @@ -48,3 +48,10 @@ function (?\DateTimeImmutable $d): void { assertType('DateTimeImmutable', $d->modify('+1 day')); assertType('DateTimeImmutable|null', $d?->modify('+1 day')); }; + +class Foo extends \DateTimeImmutable {} +class Bar extends \DateTimeImmutable {} + +function (Foo|Bar $d): void { + assertType('DateFormatReturnType\Bar|DateFormatReturnType\Foo', $d->modify('+1 day')); +}; From ed3922a931fd3a6688b1aa2ba0c971ddc056476e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 07:49:26 +0000 Subject: [PATCH 05/17] Separate nullsafe modify assertion into its own function The previous non-nullsafe call narrows the type of $d, which could affect the nullsafe assertion on the next line. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/date-format.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/date-format.php b/tests/PHPStan/Analyser/nsrt/date-format.php index 05876211568..20a623addc5 100644 --- a/tests/PHPStan/Analyser/nsrt/date-format.php +++ b/tests/PHPStan/Analyser/nsrt/date-format.php @@ -46,6 +46,9 @@ function (\DateTimeImmutable $dt, string $s): void { function (?\DateTimeImmutable $d): void { assertType('DateTimeImmutable', $d->modify('+1 day')); +}; + +function (?\DateTimeImmutable $d): void { assertType('DateTimeImmutable|null', $d?->modify('+1 day')); }; From 3240d1460a393e4c71fc56efac5425b521190a33 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 6 Apr 2026 09:55:52 +0200 Subject: [PATCH 06/17] Add failing test --- tests/PHPStan/Analyser/nsrt/date-format.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/date-format.php b/tests/PHPStan/Analyser/nsrt/date-format.php index 20a623addc5..b192cf9dec1 100644 --- a/tests/PHPStan/Analyser/nsrt/date-format.php +++ b/tests/PHPStan/Analyser/nsrt/date-format.php @@ -53,8 +53,11 @@ function (?\DateTimeImmutable $d): void { }; class Foo extends \DateTimeImmutable {} -class Bar extends \DateTimeImmutable {} +class Bar { + /** @return string */ + public function modify($string) {} +} function (Foo|Bar $d): void { - assertType('DateFormatReturnType\Bar|DateFormatReturnType\Foo', $d->modify('+1 day')); + assertType('DateFormatReturnType\Foo|string', $d->modify('+1 day')); }; From 1ede89858051da257a24b3580c53377305e369bd Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 08:22:45 +0000 Subject: [PATCH 07/17] Filter caller type to DateTimeInterface subtypes in DateTimeModifyReturnTypeExtension Instead of removeNull, filter out non-DateTime types from the caller type to correctly handle union types like Foo|Bar where only Foo extends DateTimeImmutable. Uses isSuperTypeOf check to preserve template types like T of DateTime|DateTimeImmutable. Co-Authored-By: Claude Opus 4.6 --- .../Php/DateTimeModifyReturnTypeExtension.php | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php index a4155a88eb9..8faa502a43c 100644 --- a/src/Type/Php/DateTimeModifyReturnTypeExtension.php +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -11,6 +11,7 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\NeverType; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use Throwable; @@ -77,7 +78,25 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } elseif ($hasDateTime) { - return TypeCombinator::removeNull($scope->getType($methodCall->var)); + $callerType = $scope->getType($methodCall->var); + $dateTimeInterfaceType = new ObjectType(DateTimeInterface::class); + + if ($dateTimeInterfaceType->isSuperTypeOf($callerType)->yes()) { + return $callerType; + } + + foreach ($callerType->getObjectClassNames() as $className) { + if (!$dateTimeInterfaceType->isSuperTypeOf(new ObjectType($className))->yes()) { + $callerType = TypeCombinator::remove($callerType, new ObjectType($className)); + } + } + $callerType = TypeCombinator::removeNull($callerType); + + if ($callerType instanceof NeverType) { + return null; + } + + return $callerType; } if ($this->phpVersion->hasDateTimeExceptions()) { From 35c0fd310a52cfe13e9c68e387e46738f24272cc Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 08:22:50 +0000 Subject: [PATCH 08/17] Include native return types for union members not handled by dynamic extensions When a union type like Foo|Bar has a dynamic return type extension for Foo but not Bar, the dispatch previously discarded Bar's native return type entirely. Now tracks which class names were handled by extensions and includes per-class native return types for unhandled members. Co-Authored-By: Claude Opus 4.6 --- .../Helper/MethodCallReturnTypeHelper.php | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index 0e9bc9149ef..aa6b3d8c2a6 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -9,9 +9,12 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Type\DynamicReturnTypeExtensionRegistryProvider; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_unique; use function count; +use function in_array; #[AutowiredService] final class MethodCallReturnTypeHelper @@ -52,7 +55,9 @@ public function methodCallReturnType( } $resolvedTypes = []; - foreach ($typeWithMethod->getObjectClassNames() as $className) { + $allClassNames = array_unique($typeWithMethod->getObjectClassNames()); + $handledClassNames = []; + foreach ($allClassNames as $className) { if ($normalizedMethodCall instanceof MethodCall) { foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { if (!$dynamicMethodReturnTypeExtension->isMethodSupported($methodReflection)) { @@ -65,6 +70,7 @@ public function methodCallReturnType( } $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; } } else { foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { @@ -82,11 +88,29 @@ public function methodCallReturnType( } $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; } } } if (count($resolvedTypes) > 0) { + foreach ($allClassNames as $className) { + if (in_array($className, $handledClassNames, true)) { + continue; + } + $classType = new ObjectType($className); + if (!$classType->hasMethod($methodName)->yes()) { + continue; + } + $classMethod = $classType->getMethod($methodName, $scope); + $classParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $classMethod->getVariants(), + $classMethod->getNamedArgumentsVariants(), + ); + $resolvedTypes[] = $classParametersAcceptor->getReturnType(); + } return VoidToNullTypeTransformer::transform(TypeCombinator::union(...$resolvedTypes), $methodCall); } From 53a43c1cc531c9ecfe148c9d4020a23f752b8f2b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 08:55:49 +0000 Subject: [PATCH 09/17] Use filtered remaining type instead of new ObjectType for unhandled classes Preserve generic type information by filtering the original $typeWithMethod to remove handled classes, rather than creating plain ObjectType instances which lose generics and other type details. Co-Authored-By: Claude Opus 4.6 --- .../Helper/MethodCallReturnTypeHelper.php | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index aa6b3d8c2a6..49d27836b47 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -14,7 +14,6 @@ use PHPStan\Type\TypeCombinator; use function array_unique; use function count; -use function in_array; #[AutowiredService] final class MethodCallReturnTypeHelper @@ -94,22 +93,19 @@ public function methodCallReturnType( } if (count($resolvedTypes) > 0) { - foreach ($allClassNames as $className) { - if (in_array($className, $handledClassNames, true)) { - continue; - } - $classType = new ObjectType($className); - if (!$classType->hasMethod($methodName)->yes()) { - continue; - } - $classMethod = $classType->getMethod($methodName, $scope); - $classParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $remainingType = $typeWithMethod; + foreach ($handledClassNames as $handledClassName) { + $remainingType = TypeCombinator::remove($remainingType, new ObjectType($handledClassName)); + } + if ($remainingType->hasMethod($methodName)->yes()) { + $remainingMethod = $remainingType->getMethod($methodName, $scope); + $remainingParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( $scope, $methodCall->getArgs(), - $classMethod->getVariants(), - $classMethod->getNamedArgumentsVariants(), + $remainingMethod->getVariants(), + $remainingMethod->getNamedArgumentsVariants(), ); - $resolvedTypes[] = $classParametersAcceptor->getReturnType(); + $resolvedTypes[] = $remainingParametersAcceptor->getReturnType(); } return VoidToNullTypeTransformer::transform(TypeCombinator::union(...$resolvedTypes), $methodCall); } From 457963977ae8c858a8cd0a0b0e3324945f33b06a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 09:06:49 +0000 Subject: [PATCH 10/17] Simplify DateTimeModifyReturnTypeExtension to use TypeCombinator::intersect Replace the manual loop filtering DateTimeInterface subtypes with a single TypeCombinator::intersect() call as suggested in review. Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/DateTimeModifyReturnTypeExtension.php | 14 +------------- tests/PHPStan/Analyser/nsrt/date-format.php | 2 +- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php index 8faa502a43c..bcb85421dc3 100644 --- a/src/Type/Php/DateTimeModifyReturnTypeExtension.php +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -78,19 +78,7 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } elseif ($hasDateTime) { - $callerType = $scope->getType($methodCall->var); - $dateTimeInterfaceType = new ObjectType(DateTimeInterface::class); - - if ($dateTimeInterfaceType->isSuperTypeOf($callerType)->yes()) { - return $callerType; - } - - foreach ($callerType->getObjectClassNames() as $className) { - if (!$dateTimeInterfaceType->isSuperTypeOf(new ObjectType($className))->yes()) { - $callerType = TypeCombinator::remove($callerType, new ObjectType($className)); - } - } - $callerType = TypeCombinator::removeNull($callerType); + $callerType = TypeCombinator::intersect($scope->getType($methodCall->var), new ObjectType(DateTimeInterface::class)); if ($callerType instanceof NeverType) { return null; diff --git a/tests/PHPStan/Analyser/nsrt/date-format.php b/tests/PHPStan/Analyser/nsrt/date-format.php index b192cf9dec1..bd3c9bec461 100644 --- a/tests/PHPStan/Analyser/nsrt/date-format.php +++ b/tests/PHPStan/Analyser/nsrt/date-format.php @@ -59,5 +59,5 @@ public function modify($string) {} } function (Foo|Bar $d): void { - assertType('DateFormatReturnType\Foo|string', $d->modify('+1 day')); + assertType('(DateFormatReturnType\Bar&DateTimeInterface)|DateFormatReturnType\Foo|string', $d->modify('+1 day')); }; From c94bf83534332a9cb4659b34c90e32c2e1b3fb55 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 6 Apr 2026 11:17:11 +0200 Subject: [PATCH 11/17] Improvements --- .../Helper/MethodCallReturnTypeHelper.php | 29 ++++++++++--------- .../Php/DateTimeModifyReturnTypeExtension.php | 11 +++---- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index 49d27836b47..d689a0c644d 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -93,20 +93,23 @@ public function methodCallReturnType( } if (count($resolvedTypes) > 0) { - $remainingType = $typeWithMethod; - foreach ($handledClassNames as $handledClassName) { - $remainingType = TypeCombinator::remove($remainingType, new ObjectType($handledClassName)); - } - if ($remainingType->hasMethod($methodName)->yes()) { - $remainingMethod = $remainingType->getMethod($methodName, $scope); - $remainingParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $methodCall->getArgs(), - $remainingMethod->getVariants(), - $remainingMethod->getNamedArgumentsVariants(), - ); - $resolvedTypes[] = $remainingParametersAcceptor->getReturnType(); + if (count($allClassNames) !== count($handledClassNames)) { + $remainingType = $typeWithMethod; + foreach ($handledClassNames as $handledClassName) { + $remainingType = TypeCombinator::remove($remainingType, new ObjectType($handledClassName)); + } + if ($remainingType->hasMethod($methodName)->yes()) { + $remainingMethod = $remainingType->getMethod($methodName, $scope); + $remainingParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $remainingMethod->getVariants(), + $remainingMethod->getNamedArgumentsVariants(), + ); + $resolvedTypes[] = $remainingParametersAcceptor->getReturnType(); + } } + return VoidToNullTypeTransformer::transform(TypeCombinator::union(...$resolvedTypes), $methodCall); } diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php index bcb85421dc3..becebde1631 100644 --- a/src/Type/Php/DateTimeModifyReturnTypeExtension.php +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -78,13 +78,10 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } elseif ($hasDateTime) { - $callerType = TypeCombinator::intersect($scope->getType($methodCall->var), new ObjectType(DateTimeInterface::class)); - - if ($callerType instanceof NeverType) { - return null; - } - - return $callerType; + return TypeCombinator::intersect( + $scope->getType($methodCall->var), + new ObjectType(DateTimeInterface::class), + ); } if ($this->phpVersion->hasDateTimeExceptions()) { From dc65ae7dbce48353aed097fcf95caccefdf42b62 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 09:47:14 +0000 Subject: [PATCH 12/17] Remove unnecessary array_unique call from getObjectClassNames() getObjectClassNames() already returns unique class names, so the array_unique wrapper is redundant. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index d689a0c644d..da565b78d8d 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -12,7 +12,6 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use function array_unique; use function count; #[AutowiredService] @@ -54,7 +53,7 @@ public function methodCallReturnType( } $resolvedTypes = []; - $allClassNames = array_unique($typeWithMethod->getObjectClassNames()); + $allClassNames = $typeWithMethod->getObjectClassNames(); $handledClassNames = []; foreach ($allClassNames as $className) { if ($normalizedMethodCall instanceof MethodCall) { From ea795df9ddeecf8b6b20c2d50ba2915db3a226ef Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 10:19:59 +0000 Subject: [PATCH 13/17] Add tests for MethodCallReturnTypeHelper fallback on static calls Tests the generic fallback logic in MethodCallReturnTypeHelper for static method calls on union types where only some classes are handled by dynamic return type extensions. Uses BackedEnum::from() as the extension-handled case and a plain class as the unhandled fallback case. Co-Authored-By: Claude Opus 4.6 --- .../nsrt/method-call-return-type-fallback.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/method-call-return-type-fallback.php diff --git a/tests/PHPStan/Analyser/nsrt/method-call-return-type-fallback.php b/tests/PHPStan/Analyser/nsrt/method-call-return-type-fallback.php new file mode 100644 index 00000000000..9639e09e543 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/method-call-return-type-fallback.php @@ -0,0 +1,27 @@ += 8.1 + +namespace MethodCallReturnTypeFallback; + +use function PHPStan\Testing\assertType; + +enum Suit: string { + case Hearts = 'hearts'; + case Diamonds = 'diamonds'; +} + +class MyClass { + /** @return self */ + public static function from(string $value): self { + return new self(); + } +} + +/** @param class-string|class-string $class */ +function testStaticCallOnUnionWithConstant(string $class): void { + assertType('MethodCallReturnTypeFallback\MyClass|MethodCallReturnTypeFallback\Suit::Hearts', $class::from('hearts')); +} + +/** @param class-string|class-string $class */ +function testStaticCallOnUnionWithVariable(string $class, string $value): void { + assertType('MethodCallReturnTypeFallback\MyClass|MethodCallReturnTypeFallback\Suit', $class::from($value)); +} From 1e9c190549aca9a62e9e38db9c371615fca5d0cd Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 6 Apr 2026 14:05:46 +0200 Subject: [PATCH 14/17] Fix and tests --- .../Php/DateTimeModifyReturnTypeExtension.php | 12 ++++++------ tests/PHPStan/Analyser/nsrt/bug-11073.php | 18 ++++++++++++++++++ tests/PHPStan/Analyser/nsrt/date-format.php | 12 ++++++++++-- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php index becebde1631..b4d17f0bc78 100644 --- a/src/Type/Php/DateTimeModifyReturnTypeExtension.php +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -8,10 +8,10 @@ use PHPStan\Analyser\Scope; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\NeverType; -use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use Throwable; @@ -40,11 +40,12 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type { + $args = $methodCall->getArgs(); if (count($methodCall->getArgs()) < 1) { return null; } - $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $valueType = $scope->getType($args[0]->value); $constantStrings = $valueType->getConstantStrings(); $hasFalse = false; @@ -78,10 +79,9 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } elseif ($hasDateTime) { - return TypeCombinator::intersect( - $scope->getType($methodCall->var), - new ObjectType(DateTimeInterface::class), - ); + $variant = ParametersAcceptorSelector::selectFromArgs($scope, $args, $methodReflection->getVariants()); + + return TypeCombinator::remove($variant->getReturnType(), new ConstantBooleanType(false)); } if ($this->phpVersion->hasDateTimeExceptions()) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-11073.php b/tests/PHPStan/Analyser/nsrt/bug-11073.php index 45328fc0417..b61f9f03ea8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11073.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11073.php @@ -14,3 +14,21 @@ public function sayHello(?DateTimeImmutable $date): void assertType('DateTimeImmutable|null', $date?->modify('+1 year')->setTime(23, 59, 59)); } } + +class Foo +{ + public function getCode(): bool { return false; } +} + +class HelloWorld2 +{ + public function sayHello(\Throwable|Foo $foo): void + { + assertType('bool|int|string', $foo->getCode()); + } + + public function sayHello2(\LogicException|Foo $foo): void + { + assertType('bool|int', $foo->getCode()); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/date-format.php b/tests/PHPStan/Analyser/nsrt/date-format.php index bd3c9bec461..2f11a334717 100644 --- a/tests/PHPStan/Analyser/nsrt/date-format.php +++ b/tests/PHPStan/Analyser/nsrt/date-format.php @@ -57,7 +57,15 @@ class Bar { /** @return string */ public function modify($string) {} } +class Bar2 { + /** @return string|false */ + public function modify($string) {} +} + +function foo(Foo|Bar $d): void { + assertType('DateFormatReturnType\Foo|string', $d->modify('+1 day')); +}; -function (Foo|Bar $d): void { - assertType('(DateFormatReturnType\Bar&DateTimeInterface)|DateFormatReturnType\Foo|string', $d->modify('+1 day')); +function foo2(Foo|Bar2 $d): void { + assertType('DateFormatReturnType\Foo|string|false', $d->modify('+1 day')); }; From 904c51ce0f9fd8338271e694a632dab7926f7e95 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 6 Apr 2026 14:32:19 +0200 Subject: [PATCH 15/17] Fix --- .../Php/DateTimeModifyReturnTypeExtension.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php index b4d17f0bc78..73a71634cf6 100644 --- a/src/Type/Php/DateTimeModifyReturnTypeExtension.php +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -12,6 +12,7 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\NeverType; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use Throwable; @@ -41,7 +42,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type { $args = $methodCall->getArgs(); - if (count($methodCall->getArgs()) < 1) { + if (count($args) < 1) { return null; } @@ -79,9 +80,19 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } elseif ($hasDateTime) { - $variant = ParametersAcceptorSelector::selectFromArgs($scope, $args, $methodReflection->getVariants()); + $callerType = $scope->getType($methodCall->var); - return TypeCombinator::remove($variant->getReturnType(), new ConstantBooleanType(false)); + $dateTimeInterfaceType = new ObjectType(DateTimeInterface::class); + if ($dateTimeInterfaceType->isSuperTypeOf($callerType)->yes()) { + return $callerType; + } + foreach ($callerType->getObjectClassNames() as $className) { + if (!$dateTimeInterfaceType->isSuperTypeOf(new ObjectType($className))->yes()) { + $callerType = TypeCombinator::remove($callerType, new ObjectType($className)); + } + } + + return $callerType; } if ($this->phpVersion->hasDateTimeExceptions()) { From d2b04a7225ad50b5bc57e9643c2ae359883d4a59 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 6 Apr 2026 15:54:52 +0200 Subject: [PATCH 16/17] Fix --- .../Php/DateTimeModifyReturnTypeExtension.php | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php index 73a71634cf6..4866ab11df8 100644 --- a/src/Type/Php/DateTimeModifyReturnTypeExtension.php +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -8,13 +8,14 @@ use PHPStan\Analyser\Scope; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\MethodReflection; -use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeTraverser; +use PHPStan\Type\UnionType; use Throwable; use function count; @@ -86,13 +87,19 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method if ($dateTimeInterfaceType->isSuperTypeOf($callerType)->yes()) { return $callerType; } - foreach ($callerType->getObjectClassNames() as $className) { - if (!$dateTimeInterfaceType->isSuperTypeOf(new ObjectType($className))->yes()) { - $callerType = TypeCombinator::remove($callerType, new ObjectType($className)); - } - } - return $callerType; + return TypeTraverser::map( + $callerType, + static function (Type $type, callable $traverse) use ($dateTimeInterfaceType): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + if ($dateTimeInterfaceType->isSuperTypeOf($type)->yes()) { + return $type; + } + return new NeverType(); + } + ); } if ($this->phpVersion->hasDateTimeExceptions()) { From 68cc39423cc5529fa1135633cc4ac1f101ea96f0 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 6 Apr 2026 15:57:40 +0200 Subject: [PATCH 17/17] Fix cs --- src/Type/Php/DateTimeModifyReturnTypeExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php index 4866ab11df8..9b927a19780 100644 --- a/src/Type/Php/DateTimeModifyReturnTypeExtension.php +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -98,7 +98,7 @@ static function (Type $type, callable $traverse) use ($dateTimeInterfaceType): T return $type; } return new NeverType(); - } + }, ); }