From d32d0e31eee744bb28b79e2382bce604b7df8b2c Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:05:50 +0000 Subject: [PATCH 1/7] Skip `__toString` purity check in `ImplicitToStringCallHelper` when type is not an object - Add early return in `ImplicitToStringCallHelper::processImplicitToStringCall()` when `$exprType->isObject()->no()`, since string cast on non-object types (int, float, bool, string) is a built-in operation that does not call `__toString()` - For intersection types like `int&T` (where T is a template type), `isObject()` correctly returns No because int is not an object, preventing a false "possibly impure call to method stdClass::__toString()" error - This also prevents unnecessary throw points from being generated for `__toString` on non-object types - Verified that the general method call case on intersection types (where both types have the method) is already handled correctly by `IntersectionTypeMethodReflection::isPure()` using `TrinaryLogic::lazyMaxMin` - Added regression tests for both PureFunctionRule and PureMethodRule covering string cast, string concatenation, and multiple scalar types (int, float, bool, string) intersected with template types --- .../Helper/ImplicitToStringCallHelper.php | 10 +++ .../Rules/Pure/PureFunctionRuleTest.php | 5 ++ .../PHPStan/Rules/Pure/PureMethodRuleTest.php | 6 ++ .../Rules/Pure/data/bug-14511-method.php | 34 ++++++++++ tests/PHPStan/Rules/Pure/data/bug-14511.php | 63 +++++++++++++++++++ 5 files changed, 118 insertions(+) create mode 100644 tests/PHPStan/Rules/Pure/data/bug-14511-method.php create mode 100644 tests/PHPStan/Rules/Pure/data/bug-14511.php diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index 1b66bc7ba38..20dd8ba2d8d 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -28,6 +28,16 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E $impurePoints = []; $exprType = $scope->getType($expr); + if ($exprType->isObject()->no()) { + return new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + ); + } + $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); if ($toStringMethod === null) { return new ExpressionResult( diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index 9fa246f5552..8e172d035a0 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -214,4 +214,9 @@ public function testBug14504(): void $this->analyse([__DIR__ . '/data/bug-14504.php'], []); } + public function testBug14511(): void + { + $this->analyse([__DIR__ . '/data/bug-14511.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index 2b896c17b10..88bb38c5d40 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -383,4 +383,10 @@ public function testBug14504(): void $this->analyse([__DIR__ . '/data/bug-14504-method.php'], []); } + public function testBug14511(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-14511-method.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Pure/data/bug-14511-method.php b/tests/PHPStan/Rules/Pure/data/bug-14511-method.php new file mode 100644 index 00000000000..8865d555edd --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/bug-14511-method.php @@ -0,0 +1,34 @@ + Date: Wed, 22 Apr 2026 13:44:35 +0000 Subject: [PATCH 2/7] Move fix from ImplicitToStringCallHelper to IntersectionType::hasMethod() Instead of guarding against non-object types in ImplicitToStringCallHelper, fix the root cause in IntersectionType::hasMethod(). When any type in the intersection is definitively not an object and definitively doesn't have the method, the intersection should return No. This prevents MixedType::hasMethod() (which returns Yes for all methods) from dominating via TrinaryLogic::lazyMaxMin's early-return-on-Yes behavior when intersected with concrete non-object types like int. The check is targeted: class-string types return Maybe (not No) from hasMethod() for methods that might exist on the referenced class, so the guard doesn't trigger for class-string intersections. Co-Authored-By: Claude Opus 4.6 --- .../ExprHandler/Helper/ImplicitToStringCallHelper.php | 10 ---------- src/Type/IntersectionType.php | 6 ++++++ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index 20dd8ba2d8d..1b66bc7ba38 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -28,16 +28,6 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E $impurePoints = []; $exprType = $scope->getType($expr); - if ($exprType->isObject()->no()) { - return new ExpressionResult( - $scope, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); if ($toStringMethod === null) { return new ExpressionResult( diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 9dd35d0e60b..4752c0ba453 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -656,6 +656,12 @@ public function canCallMethods(): TrinaryLogic public function hasMethod(string $methodName): TrinaryLogic { + foreach ($this->types as $type) { + if ($type->isObject()->no() && $type->hasMethod($methodName)->no()) { + return TrinaryLogic::createNo(); + } + } + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasMethod($methodName)); } From 65cc37251b922f577e2739a27d1d44a2a5436eec Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 22 Apr 2026 14:49:12 +0000 Subject: [PATCH 3/7] Apply same non-object guard to hasProperty, hasInstanceProperty, hasStaticProperty, hasConstant The same lazyMaxMin early-return issue from hasMethod() affects these methods: MixedType returns Yes for all of them, which dominates a non-object type's No via the early return in lazyMaxMin. This causes incorrect Yes results for intersection types like int&T where T is a template bounded by mixed. Co-Authored-By: Claude Opus 4.6 --- src/Type/IntersectionType.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 4752c0ba453..041d700eade 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -552,6 +552,12 @@ public function canAccessProperties(): TrinaryLogic public function hasProperty(string $propertyName): TrinaryLogic { + foreach ($this->types as $type) { + if ($type->isObject()->no() && $type->hasProperty($propertyName)->no()) { + return TrinaryLogic::createNo(); + } + } + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasProperty($propertyName)); } @@ -585,6 +591,12 @@ public function getUnresolvedPropertyPrototype(string $propertyName, ClassMember public function hasInstanceProperty(string $propertyName): TrinaryLogic { + foreach ($this->types as $type) { + if ($type->isObject()->no() && $type->hasInstanceProperty($propertyName)->no()) { + return TrinaryLogic::createNo(); + } + } + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasInstanceProperty($propertyName)); } @@ -618,6 +630,12 @@ public function getUnresolvedInstancePropertyPrototype(string $propertyName, Cla public function hasStaticProperty(string $propertyName): TrinaryLogic { + foreach ($this->types as $type) { + if ($type->isObject()->no() && $type->hasStaticProperty($propertyName)->no()) { + return TrinaryLogic::createNo(); + } + } + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasStaticProperty($propertyName)); } @@ -700,6 +718,12 @@ public function canAccessConstants(): TrinaryLogic public function hasConstant(string $constantName): TrinaryLogic { + foreach ($this->types as $type) { + if ($type->isObject()->no() && $type->hasConstant($constantName)->no()) { + return TrinaryLogic::createNo(); + } + } + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasConstant($constantName)); } From 992a66608a241dac26d8df420b0f05b742895aa2 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 22 Apr 2026 15:06:56 +0000 Subject: [PATCH 4/7] Extract intersectResultsWithNonObjectGuard() to avoid duplicate has* calls The non-object guard and intersectResults were calling hasMethod/hasProperty/etc. twice per type. A single-pass private method now computes each result once, checks the guard, and feeds pre-computed results to TrinaryLogic::maxMin. Co-Authored-By: Claude Opus 4.6 --- src/Type/IntersectionType.php | 57 ++++++++++++++--------------------- 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 041d700eade..ebf674b9f04 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -552,13 +552,7 @@ public function canAccessProperties(): TrinaryLogic public function hasProperty(string $propertyName): TrinaryLogic { - foreach ($this->types as $type) { - if ($type->isObject()->no() && $type->hasProperty($propertyName)->no()) { - return TrinaryLogic::createNo(); - } - } - - return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasProperty($propertyName)); + return $this->intersectResultsWithNonObjectGuard(static fn (Type $type): TrinaryLogic => $type->hasProperty($propertyName)); } public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection @@ -591,13 +585,7 @@ public function getUnresolvedPropertyPrototype(string $propertyName, ClassMember public function hasInstanceProperty(string $propertyName): TrinaryLogic { - foreach ($this->types as $type) { - if ($type->isObject()->no() && $type->hasInstanceProperty($propertyName)->no()) { - return TrinaryLogic::createNo(); - } - } - - return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasInstanceProperty($propertyName)); + return $this->intersectResultsWithNonObjectGuard(static fn (Type $type): TrinaryLogic => $type->hasInstanceProperty($propertyName)); } public function getInstanceProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection @@ -630,13 +618,7 @@ public function getUnresolvedInstancePropertyPrototype(string $propertyName, Cla public function hasStaticProperty(string $propertyName): TrinaryLogic { - foreach ($this->types as $type) { - if ($type->isObject()->no() && $type->hasStaticProperty($propertyName)->no()) { - return TrinaryLogic::createNo(); - } - } - - return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasStaticProperty($propertyName)); + return $this->intersectResultsWithNonObjectGuard(static fn (Type $type): TrinaryLogic => $type->hasStaticProperty($propertyName)); } public function getStaticProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection @@ -674,13 +656,7 @@ public function canCallMethods(): TrinaryLogic public function hasMethod(string $methodName): TrinaryLogic { - foreach ($this->types as $type) { - if ($type->isObject()->no() && $type->hasMethod($methodName)->no()) { - return TrinaryLogic::createNo(); - } - } - - return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasMethod($methodName)); + return $this->intersectResultsWithNonObjectGuard(static fn (Type $type): TrinaryLogic => $type->hasMethod($methodName)); } public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection @@ -718,13 +694,7 @@ public function canAccessConstants(): TrinaryLogic public function hasConstant(string $constantName): TrinaryLogic { - foreach ($this->types as $type) { - if ($type->isObject()->no() && $type->hasConstant($constantName)->no()) { - return TrinaryLogic::createNo(); - } - } - - return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasConstant($constantName)); + return $this->intersectResultsWithNonObjectGuard(static fn (Type $type): TrinaryLogic => $type->hasConstant($constantName)); } public function getConstant(string $constantName): ClassConstantReflection @@ -1513,6 +1483,23 @@ private function intersectResults( return TrinaryLogic::lazyMaxMin($types, $getResult); } + /** + * @param callable(Type $type): TrinaryLogic $getResult + */ + private function intersectResultsWithNonObjectGuard(callable $getResult): TrinaryLogic + { + $results = []; + foreach ($this->types as $type) { + $result = $getResult($type); + if ($type->isObject()->no() && $result->no()) { + return TrinaryLogic::createNo(); + } + $results[] = $result; + } + + return TrinaryLogic::maxMin(...$results); + } + /** * @param callable(Type $type): Type $getType */ From e35ca4417b06936fdd9f47531351f32c39f4d8b4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 22 Apr 2026 17:24:13 +0000 Subject: [PATCH 5/7] Narrow TemplateMixedType in TypeCombinator::intersect() for scalar types Revert IntersectionType changes and instead narrow TemplateMixedType to a concrete template type (e.g. TemplateIntegerType) when intersected with a scalar type in TypeCombinator::intersect(). For `int & T` where T is TemplateMixedType, the result is now `T of int` (TemplateIntegerType) instead of an IntersectionType. Since TemplateIntegerType delegates hasMethod() to IntegerType (which returns No for __toString), the false positive purity error is avoided. The narrowing is restricted to scalar types only. Narrowing object or array types would cause information loss during template resolution (e.g. `T&MockObject` becoming just the resolved type without MockObject). Co-Authored-By: Claude Opus 4.6 --- src/Type/IntersectionType.php | 27 +++--------- src/Type/TypeCombinator.php | 44 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-4117.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-4498.php | 2 +- .../CallToFunctionParametersRuleTest.php | 4 +- 5 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index ebf674b9f04..9dd35d0e60b 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -552,7 +552,7 @@ public function canAccessProperties(): TrinaryLogic public function hasProperty(string $propertyName): TrinaryLogic { - return $this->intersectResultsWithNonObjectGuard(static fn (Type $type): TrinaryLogic => $type->hasProperty($propertyName)); + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasProperty($propertyName)); } public function getProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection @@ -585,7 +585,7 @@ public function getUnresolvedPropertyPrototype(string $propertyName, ClassMember public function hasInstanceProperty(string $propertyName): TrinaryLogic { - return $this->intersectResultsWithNonObjectGuard(static fn (Type $type): TrinaryLogic => $type->hasInstanceProperty($propertyName)); + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasInstanceProperty($propertyName)); } public function getInstanceProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection @@ -618,7 +618,7 @@ public function getUnresolvedInstancePropertyPrototype(string $propertyName, Cla public function hasStaticProperty(string $propertyName): TrinaryLogic { - return $this->intersectResultsWithNonObjectGuard(static fn (Type $type): TrinaryLogic => $type->hasStaticProperty($propertyName)); + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasStaticProperty($propertyName)); } public function getStaticProperty(string $propertyName, ClassMemberAccessAnswerer $scope): ExtendedPropertyReflection @@ -656,7 +656,7 @@ public function canCallMethods(): TrinaryLogic public function hasMethod(string $methodName): TrinaryLogic { - return $this->intersectResultsWithNonObjectGuard(static fn (Type $type): TrinaryLogic => $type->hasMethod($methodName)); + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasMethod($methodName)); } public function getMethod(string $methodName, ClassMemberAccessAnswerer $scope): ExtendedMethodReflection @@ -694,7 +694,7 @@ public function canAccessConstants(): TrinaryLogic public function hasConstant(string $constantName): TrinaryLogic { - return $this->intersectResultsWithNonObjectGuard(static fn (Type $type): TrinaryLogic => $type->hasConstant($constantName)); + return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasConstant($constantName)); } public function getConstant(string $constantName): ClassConstantReflection @@ -1483,23 +1483,6 @@ private function intersectResults( return TrinaryLogic::lazyMaxMin($types, $getResult); } - /** - * @param callable(Type $type): TrinaryLogic $getResult - */ - private function intersectResultsWithNonObjectGuard(callable $getResult): TrinaryLogic - { - $results = []; - foreach ($this->types as $type) { - $result = $getResult($type); - if ($type->isObject()->no() && $result->no()) { - return TrinaryLogic::createNo(); - } - $results[] = $result; - } - - return TrinaryLogic::maxMin(...$results); - } - /** * @param callable(Type $type): Type $getType */ diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 901c78de7ee..9953a4bc7f1 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1399,6 +1399,50 @@ public static function intersect(Type ...$types): Type continue; } + if ( + $types[$j] instanceof TemplateMixedType + && !$types[$i] instanceof TemplateType + && $types[$i]->isScalar()->yes() + && $types[$j]->getBound()->isSuperTypeOf($types[$i])->yes() + ) { + $narrowed = TemplateTypeFactory::create( + $types[$j]->getScope(), + $types[$j]->getName(), + $types[$i], + $types[$j]->getVariance(), + $types[$j]->getStrategy(), + $types[$j]->getDefault(), + ); + if (!$narrowed instanceof TemplateMixedType) { + $types[$j] = $narrowed; + array_splice($types, $i--, 1); + $typesCount--; + continue 2; + } + } + + if ( + $types[$i] instanceof TemplateMixedType + && !$types[$j] instanceof TemplateType + && $types[$j]->isScalar()->yes() + && $types[$i]->getBound()->isSuperTypeOf($types[$j])->yes() + ) { + $narrowed = TemplateTypeFactory::create( + $types[$i]->getScope(), + $types[$i]->getName(), + $types[$j], + $types[$i]->getVariance(), + $types[$i]->getStrategy(), + $types[$i]->getDefault(), + ); + if (!$narrowed instanceof TemplateMixedType) { + $types[$i] = $narrowed; + array_splice($types, $j--, 1); + $typesCount--; + continue; + } + } + if ($types[$i] instanceof IterableType) { $isSuperTypeB = $types[$i]->isSuperTypeOfMixed($types[$j]); } else { diff --git a/tests/PHPStan/Analyser/nsrt/bug-4117.php b/tests/PHPStan/Analyser/nsrt/bug-4117.php index 14732b77b6b..f6a406bcfa7 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4117.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4117.php @@ -34,7 +34,7 @@ public function broken(int $key) if ($item) { assertType("T of mixed~(0|0.0|''|'0'|array{}|false|null) (class Bug4117Types\GenericList, argument)", $item); } else { - assertType("(T of mixed~null (class Bug4117Types\GenericList, argument)&false)|(0.0&T of mixed~null (class Bug4117Types\GenericList, argument))|(0&T of mixed~null (class Bug4117Types\GenericList, argument))|(list{}&T of mixed~null (class Bug4117Types\GenericList, argument))|(''&T of mixed~null (class Bug4117Types\GenericList, argument))|('0'&T of mixed~null (class Bug4117Types\GenericList, argument))|null", $item); + assertType("T of 0 (class Bug4117Types\GenericList, argument)|T of '' (class Bug4117Types\GenericList, argument)|T of '0' (class Bug4117Types\GenericList, argument)|(T of mixed~null (class Bug4117Types\GenericList, argument)&false)|(0.0&T of mixed~null (class Bug4117Types\GenericList, argument))|(list{}&T of mixed~null (class Bug4117Types\GenericList, argument))|null", $item); } assertType('T of mixed~null (class Bug4117Types\GenericList, argument)|null', $item); diff --git a/tests/PHPStan/Analyser/nsrt/bug-4498.php b/tests/PHPStan/Analyser/nsrt/bug-4498.php index ad07baa3db4..68e652b159a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4498.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4498.php @@ -38,7 +38,7 @@ public function fcn(iterable $iterable): iterable public function bar(iterable $iterable): iterable { if (is_array($iterable)) { - assertType('array<((int&TKey (method Bug4498\Foo::bar(), argument))|(string&TKey (method Bug4498\Foo::bar(), argument))), TValue (method Bug4498\Foo::bar(), argument)>', $iterable); + assertType('array<(TKey of int (method Bug4498\Foo::bar(), argument)|TKey of string (method Bug4498\Foo::bar(), argument)), TValue (method Bug4498\Foo::bar(), argument)>', $iterable); return $iterable; } diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index a202c8e271d..e086b2ea02b 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -725,11 +725,11 @@ public function testArrayUdiffCallback(): void 14, ], [ - 'Parameter #1 $arr1 of function array_udiff expects array<(int&TK)|(string&TK), string>, null given.', + 'Parameter #1 $arr1 of function array_udiff expects array<(TK of int)|TK of string, string>, null given.', 20, ], [ - 'Parameter #2 $arr2 of function array_udiff expects array<(int&TK)|(string&TK), string>, null given.', + 'Parameter #2 $arr2 of function array_udiff expects array<(TK of int)|TK of string, string>, null given.', 21, ], [ From 13c22db4fc38fb7321a91a7b6f8feb1e5c247853 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 23 Apr 2026 05:43:10 +0000 Subject: [PATCH 6/7] Exclude constant scalar types from TemplateMixedType narrowing in TypeCombinator::intersect() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constant scalar values like 0, '', '0' should not be narrowed from `0&T` to `T of 0` — the intersection form is more precise for constants. Only general scalar types (int, string, float, bool) are narrowed. Co-Authored-By: Claude Opus 4.6 --- src/Type/TypeCombinator.php | 2 ++ tests/PHPStan/Analyser/nsrt/bug-4117.php | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 9953a4bc7f1..990e6b57263 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1403,6 +1403,7 @@ public static function intersect(Type ...$types): Type $types[$j] instanceof TemplateMixedType && !$types[$i] instanceof TemplateType && $types[$i]->isScalar()->yes() + && !$types[$i]->isConstantScalarValue()->yes() && $types[$j]->getBound()->isSuperTypeOf($types[$i])->yes() ) { $narrowed = TemplateTypeFactory::create( @@ -1425,6 +1426,7 @@ public static function intersect(Type ...$types): Type $types[$i] instanceof TemplateMixedType && !$types[$j] instanceof TemplateType && $types[$j]->isScalar()->yes() + && !$types[$j]->isConstantScalarValue()->yes() && $types[$i]->getBound()->isSuperTypeOf($types[$j])->yes() ) { $narrowed = TemplateTypeFactory::create( diff --git a/tests/PHPStan/Analyser/nsrt/bug-4117.php b/tests/PHPStan/Analyser/nsrt/bug-4117.php index f6a406bcfa7..14732b77b6b 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4117.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4117.php @@ -34,7 +34,7 @@ public function broken(int $key) if ($item) { assertType("T of mixed~(0|0.0|''|'0'|array{}|false|null) (class Bug4117Types\GenericList, argument)", $item); } else { - assertType("T of 0 (class Bug4117Types\GenericList, argument)|T of '' (class Bug4117Types\GenericList, argument)|T of '0' (class Bug4117Types\GenericList, argument)|(T of mixed~null (class Bug4117Types\GenericList, argument)&false)|(0.0&T of mixed~null (class Bug4117Types\GenericList, argument))|(list{}&T of mixed~null (class Bug4117Types\GenericList, argument))|null", $item); + assertType("(T of mixed~null (class Bug4117Types\GenericList, argument)&false)|(0.0&T of mixed~null (class Bug4117Types\GenericList, argument))|(0&T of mixed~null (class Bug4117Types\GenericList, argument))|(list{}&T of mixed~null (class Bug4117Types\GenericList, argument))|(''&T of mixed~null (class Bug4117Types\GenericList, argument))|('0'&T of mixed~null (class Bug4117Types\GenericList, argument))|null", $item); } assertType('T of mixed~null (class Bug4117Types\GenericList, argument)|null', $item); From 8b2bf86155b9aaff803170940115f9549a18662a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 23 Apr 2026 07:17:29 +0000 Subject: [PATCH 7/7] Add isObject() guard in ImplicitToStringCallHelper for constant scalar intersections The TypeCombinator narrowing excludes constant scalar values (0, '', '0', false, 0.0) because TemplateTypeFactory can't create proper template types for all of them. This means types like `false&T` and `0.0&T` remain as intersections where MixedType::hasMethod('__toString') returns Yes, causing false positive purity errors. Add an early return in ImplicitToStringCallHelper when isObject()->no() to handle these remaining cases. Since __toString() can only be called on objects, non-object types always use PHP's built-in string conversion. Add test cases using empty() + !\is_array() which narrows to constant scalar falsy values. Co-Authored-By: Claude Opus 4.6 --- .../Helper/ImplicitToStringCallHelper.php | 9 +++++++++ tests/PHPStan/Rules/Pure/data/bug-14511-method.php | 13 +++++++++++++ tests/PHPStan/Rules/Pure/data/bug-14511.php | 12 ++++++++++++ 3 files changed, 34 insertions(+) diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index 1b66bc7ba38..e7177fb9b4c 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -28,6 +28,15 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E $impurePoints = []; $exprType = $scope->getType($expr); + if ($exprType->isObject()->no()) { + return new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + ); + } $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); if ($toStringMethod === null) { return new ExpressionResult( diff --git a/tests/PHPStan/Rules/Pure/data/bug-14511-method.php b/tests/PHPStan/Rules/Pure/data/bug-14511-method.php index 8865d555edd..5b8b15030a5 100644 --- a/tests/PHPStan/Rules/Pure/data/bug-14511-method.php +++ b/tests/PHPStan/Rules/Pure/data/bug-14511-method.php @@ -31,4 +31,17 @@ public function testStringConcat(mixed $val): ?string return null; } + /** + * @phpstan-pure + * @template T of mixed + * @param T $val + */ + public function testEmptyNonArray(mixed $val): ?string + { + if (empty($val) && !\is_array($val)) { + return (string) $val; + } + return null; + } + } diff --git a/tests/PHPStan/Rules/Pure/data/bug-14511.php b/tests/PHPStan/Rules/Pure/data/bug-14511.php index 287e4dbb59c..72569f7799a 100644 --- a/tests/PHPStan/Rules/Pure/data/bug-14511.php +++ b/tests/PHPStan/Rules/Pure/data/bug-14511.php @@ -61,3 +61,15 @@ function testStringVal(mixed $val): ?string { } return null; } + +/** + * @phpstan-pure + * @template T of mixed + * @param T $val + */ +function testEmptyNonArray(mixed $val): ?string { + if (empty($val) && !\is_array($val)) { + return (string) $val; + } + return null; +}