From eb57bfd972de8e971389c7a62c82aea120f2b706 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:49:59 +0000 Subject: [PATCH 1/4] Infer `decimal-int-string` when casting `int`/`int` to `string` - `IntegerType::toString()` now returns `string&decimal-int-string` instead of `string&lowercase-string&uppercase-string&numeric-string`. Casting an int to string always yields the canonical decimal representation (no leading `+`/zeros, no exponent), which is exactly `decimal-int-string` (a subtype of numeric/lowercase/uppercase, so this is strictly more precise). - `IntegerRangeType::toString()` mirrors the same change for the non-finite branches, keeping `non-falsy-string` for ranges that exclude `0`. - `(string) bool` and `(string) constant-int` were already precise (`''|'1'` / constant strings), so no change was needed there. - Fixed an adjacent inconsistency: `IntersectionType::toArrayKey()` coerced `decimal-int-string` to `int` unconditionally, ignoring `ReportUnsafeArrayStringKeyCastingToggle`, while `StringType::toArrayKey()` respects it. This made `[$decimalIntString => 1]` infer `array` by default even though `[$plainString => 1]` stays `array`, and reintroduced the bug #4671 false positive (`isset($stringKeyedArray[(string) $int])`). The decimal branch now stays a string key unless the toggle is `PREVENT`, matching `StringType`. - Updated `decimal-int-string.php` to reflect the corrected lenient default (`[$s => 1]` is `array`), and refreshed the expected types in the cast/array-key inference tests that now report the more precise `decimal-int-string`. --- src/Type/IntegerRangeType.php | 12 ++---- src/Type/IntegerType.php | 8 +--- src/Type/IntersectionType.php | 5 +++ .../Analyser/nsrt/array-key-exists.php | 8 ++-- tests/PHPStan/Analyser/nsrt/bug-11716.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-12393b.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-14525.php | 12 +++--- tests/PHPStan/Analyser/nsrt/bug-14753.php | 37 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-4587.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-7387.php | 8 ++-- tests/PHPStan/Analyser/nsrt/bug-8635.php | 2 +- .../Analyser/nsrt/cast-to-numeric-string.php | 28 +++++++------- .../Analyser/nsrt/decimal-int-string.php | 2 +- tests/PHPStan/Analyser/nsrt/filter-var.php | 2 +- tests/PHPStan/Analyser/nsrt/generics.php | 6 +-- tests/PHPStan/Analyser/nsrt/key-exists.php | 8 ++-- .../PHPStan/Analyser/nsrt/range-to-string.php | 2 +- .../nsrt/set-type-type-specifying.php | 2 +- tests/PHPStan/Analyser/nsrt/strval.php | 4 +- .../PHPStan/Rules/Generics/data/bug-6301.php | 2 +- 20 files changed, 95 insertions(+), 63 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14753.php diff --git a/src/Type/IntegerRangeType.php b/src/Type/IntegerRangeType.php index 50eac67a04e..a931894c084 100644 --- a/src/Type/IntegerRangeType.php +++ b/src/Type/IntegerRangeType.php @@ -10,10 +10,8 @@ use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\TrinaryLogic; -use PHPStan\Type\Accessory\AccessoryLowercaseStringType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; -use PHPStan\Type\Accessory\AccessoryNumericStringType; -use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; use function array_filter; @@ -507,18 +505,14 @@ public function toString(): Type if ($isZero->no()) { return new IntersectionType([ new StringType(), - new AccessoryLowercaseStringType(), - new AccessoryUppercaseStringType(), - new AccessoryNumericStringType(), + new AccessoryDecimalIntegerStringType(), new AccessoryNonFalsyStringType(), ]); } return new IntersectionType([ new StringType(), - new AccessoryLowercaseStringType(), - new AccessoryUppercaseStringType(), - new AccessoryNumericStringType(), + new AccessoryDecimalIntegerStringType(), ]); } diff --git a/src/Type/IntegerType.php b/src/Type/IntegerType.php index a378a7e7bcd..45be2ae49f3 100644 --- a/src/Type/IntegerType.php +++ b/src/Type/IntegerType.php @@ -6,9 +6,7 @@ use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\TrinaryLogic; -use PHPStan\Type\Accessory\AccessoryLowercaseStringType; -use PHPStan\Type\Accessory\AccessoryNumericStringType; -use PHPStan\Type\Accessory\AccessoryUppercaseStringType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; @@ -81,9 +79,7 @@ public function toString(): Type { return new IntersectionType([ new StringType(), - new AccessoryLowercaseStringType(), - new AccessoryUppercaseStringType(), - new AccessoryNumericStringType(), + new AccessoryDecimalIntegerStringType(), ]); } diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 98d07e1ad0b..7340be0b253 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -2,6 +2,7 @@ namespace PHPStan\Type; +use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Internal\CombinationsHelper; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; @@ -1554,6 +1555,10 @@ public function toArray(): Type public function toArrayKey(): Type { if ($this->isDecimalIntegerString()->yes()) { + if (ReportUnsafeArrayStringKeyCastingToggle::getLevel() !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + return $this; + } + return new IntegerType(); } diff --git a/tests/PHPStan/Analyser/nsrt/array-key-exists.php b/tests/PHPStan/Analyser/nsrt/array-key-exists.php index 3d0505a9b9b..a06bba9ac25 100644 --- a/tests/PHPStan/Analyser/nsrt/array-key-exists.php +++ b/tests/PHPStan/Analyser/nsrt/array-key-exists.php @@ -50,16 +50,16 @@ public function doBar(array $a, array $b, array $c, int $key1, string $key2, int assertType('int', $key1); } if (array_key_exists($key2, $a)) { - assertType('lowercase-string&numeric-string&uppercase-string', $key2); + assertType('decimal-int-string', $key2); } if (array_key_exists($key3, $a)) { - assertType('int|(lowercase-string&numeric-string&uppercase-string)', $key3); + assertType('int|decimal-int-string', $key3); } if (array_key_exists($key4, $a)) { - assertType('(int|(lowercase-string&numeric-string&uppercase-string))', $key4); + assertType('(int|decimal-int-string)', $key4); } if (array_key_exists($key5, $a)) { - assertType('int|(lowercase-string&numeric-string&uppercase-string)', $key5); + assertType('int|decimal-int-string', $key5); } if (array_key_exists($key1, $b)) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-11716.php b/tests/PHPStan/Analyser/nsrt/bug-11716.php index 3dced2a08d1..7719f96c0eb 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11716.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11716.php @@ -75,7 +75,7 @@ function narrowKey($mixed, string $s, int $i, array $generalArr, array $intKeyed assertType('int', $i); if (isset($intKeyedArr[$s])) { - assertType("lowercase-string&numeric-string&uppercase-string", $s); + assertType('decimal-int-string', $s); } else { assertType('string', $s); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12393b.php b/tests/PHPStan/Analyser/nsrt/bug-12393b.php index 944646d3656..e39b19fb949 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12393b.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12393b.php @@ -343,7 +343,7 @@ class FooIntString public function doFoo(int $b): void { $this->foo = $b; - assertType('lowercase-string&numeric-string&uppercase-string', $this->foo); + assertType('decimal-int-string', $this->foo); } public function doBar(): void @@ -418,7 +418,7 @@ class FooNullableIntString public function doFoo(?int $b): void { $this->foo = $b; - assertType('lowercase-string&numeric-string&uppercase-string', $this->foo); + assertType('decimal-int-string', $this->foo); } public function doBar(): void diff --git a/tests/PHPStan/Analyser/nsrt/bug-14525.php b/tests/PHPStan/Analyser/nsrt/bug-14525.php index e478aa44a3e..93712dc8c22 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14525.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14525.php @@ -20,7 +20,7 @@ function arrayWalkGeneric(): void array_walk($array, function (&$value, $key): void { $value = (string) $value; }); - assertType("array", $array); + assertType('array', $array); } function arrayWalkNoModification(): void @@ -63,7 +63,7 @@ function arrayWalkNonEmptyArray(): void array_walk($array, function (&$value): void { $value = (string) $value; }); - assertType("non-empty-array", $array); + assertType('non-empty-array', $array); } function arrayWalkList(): void @@ -73,7 +73,7 @@ function arrayWalkList(): void array_walk($list, function (&$value): void { $value = (string) $value; }); - assertType("list", $list); + assertType('list', $list); } function arrayWalkAlwaysTerminating(): void @@ -84,7 +84,7 @@ function arrayWalkAlwaysTerminating(): void $value = (string) $value; return; }); - assertType("array", $array); + assertType('array', $array); } function arrayWalkNestedArray(): void @@ -106,7 +106,7 @@ function arrayWalkWithNestedClosure(): void }, [1, 2, 3]); $value = (string) $value; }); - assertType("array", $array); + assertType('array', $array); } function arrayWalkWithNestedClosureByRef(): void @@ -121,5 +121,5 @@ function arrayWalkWithNestedClosureByRef(): void $fn(); $value = (string) $value; }); - assertType("array", $array); + assertType('array', $array); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14753.php b/tests/PHPStan/Analyser/nsrt/bug-14753.php new file mode 100644 index 00000000000..74d45a1bcbe --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14753.php @@ -0,0 +1,37 @@ + $positive + * @param int $negative + * @param int<-10, 10> $withZero + * @param int<0, max> $nonNegative + */ + public function sayHello( + int $int, + int $positive, + int $negative, + int $withZero, + int $nonNegative, + bool $bool, + ): void + { + assertType('decimal-int-string', (string) $int); + assertType('decimal-int-string&non-falsy-string', (string) $positive); + assertType('decimal-int-string&non-falsy-string', (string) $negative); + assertType('decimal-int-string', (string) $nonNegative); + assertType("'-1'|'-10'|'-2'|'-3'|'-4'|'-5'|'-6'|'-7'|'-8'|'-9'|'0'|'1'|'10'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'", (string) $withZero); + assertType("''|'1'", (string) $bool); + assertType("'1'", (string) true); + assertType("''", (string) false); + assertType("'5'", (string) 5); + assertType("'-5'", (string) -5); + assertType('decimal-int-string', $int . ''); + assertType('decimal-int-string', strval($int)); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-4587.php b/tests/PHPStan/Analyser/nsrt/bug-4587.php index 061c953a286..a811193ae33 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4587.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4587.php @@ -27,11 +27,11 @@ public function b(): void $type = array_map(static function (array $result): array { assertType('array{a: int}', $result); $result['a'] = (string) $result['a']; - assertType('array{a: lowercase-string&numeric-string&uppercase-string}', $result); + assertType('array{a: decimal-int-string}', $result); return $result; }, $results); - assertType('list', $type); + assertType('list', $type); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-7387.php b/tests/PHPStan/Analyser/nsrt/bug-7387.php index 1b283a79901..c0f518ff43b 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7387.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7387.php @@ -29,7 +29,7 @@ public function inputTypes(int $i, float $f, string $s, int $intRange) { public function specifiers(int $i) { // https://3v4l.org/fmVIg - assertType('lowercase-string&numeric-string&uppercase-string', sprintf('%14s', $i)); + assertType('decimal-int-string', sprintf('%14s', $i)); assertType('lowercase-string&numeric-string', sprintf('%d', $i)); @@ -59,9 +59,9 @@ public function specifiers(int $i) { */ public function positionalArgs($mixed, int $i, float $f, string $s, int $posInt, int $negInt, int $nonZeroIntRange, int $intRange) { // https://3v4l.org/vVL0c - assertType('lowercase-string&numeric-string&uppercase-string', sprintf('%2$6s', $mixed, $i)); - assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', sprintf('%2$6s', $mixed, $posInt)); - assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', sprintf('%2$6s', $mixed, $negInt)); + assertType('decimal-int-string', sprintf('%2$6s', $mixed, $i)); + assertType('decimal-int-string&non-falsy-string', sprintf('%2$6s', $mixed, $posInt)); + assertType('decimal-int-string&non-falsy-string', sprintf('%2$6s', $mixed, $negInt)); assertType("' 1'|' 2'|' 3'|' 4'|' 5'", sprintf('%2$6s', $mixed, $nonZeroIntRange)); // https://3v4l.org/1ECIq diff --git a/tests/PHPStan/Analyser/nsrt/bug-8635.php b/tests/PHPStan/Analyser/nsrt/bug-8635.php index 46d5b728b86..45c09fc4e88 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8635.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8635.php @@ -8,6 +8,6 @@ class HelloWorld { public function EchoInt(int $value): void { - assertType('lowercase-string&numeric-string&uppercase-string', "$value"); + assertType('decimal-int-string', "$value"); } } diff --git a/tests/PHPStan/Analyser/nsrt/cast-to-numeric-string.php b/tests/PHPStan/Analyser/nsrt/cast-to-numeric-string.php index dfe47aa39b3..6ad4a930e7e 100644 --- a/tests/PHPStan/Analyser/nsrt/cast-to-numeric-string.php +++ b/tests/PHPStan/Analyser/nsrt/cast-to-numeric-string.php @@ -13,13 +13,13 @@ * @param 1 $constantInt */ function foo(int $a, float $b, $numeric, $numeric2, $number, $positive, $negative, $constantInt): void { - assertType('lowercase-string&numeric-string&uppercase-string', (string)$a); + assertType('decimal-int-string', (string)$a); assertType('numeric-string&uppercase-string', (string)$b); assertType('numeric-string', (string)$numeric); assertType('numeric-string', (string)$numeric2); assertType('numeric-string&uppercase-string', (string)$number); - assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', (string)$positive); - assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', (string)$negative); + assertType('decimal-int-string&non-falsy-string', (string)$positive); + assertType('decimal-int-string&non-falsy-string', (string)$negative); assertType("'1'", (string)$constantInt); } @@ -32,28 +32,28 @@ function foo(int $a, float $b, $numeric, $numeric2, $number, $positive, $negativ * @param 1 $constantInt */ function concatEmptyString(int $a, float $b, $numeric, $numeric2, $number, $positive, $negative, $constantInt): void { - assertType('lowercase-string&numeric-string&uppercase-string', '' . $a); + assertType('decimal-int-string', '' . $a); assertType('numeric-string&uppercase-string', '' . $b); assertType('numeric-string', '' . $numeric); assertType('numeric-string', '' . $numeric2); assertType('numeric-string&uppercase-string', '' . $number); - assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', '' . $positive); - assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', '' . $negative); + assertType('decimal-int-string&non-falsy-string', '' . $positive); + assertType('decimal-int-string&non-falsy-string', '' . $negative); assertType("'1'", '' . $constantInt); - assertType('lowercase-string&numeric-string&uppercase-string', $a . ''); + assertType('decimal-int-string', $a . ''); assertType('numeric-string&uppercase-string', $b . ''); assertType('numeric-string', $numeric . ''); assertType('numeric-string', $numeric2 . ''); assertType('numeric-string&uppercase-string', $number . ''); - assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', $positive . ''); - assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', $negative . ''); + assertType('decimal-int-string&non-falsy-string', $positive . ''); + assertType('decimal-int-string&non-falsy-string', $negative . ''); assertType("'1'", $constantInt . ''); } function concatAssignEmptyString(int $i, float $f) { $i .= ''; - assertType('lowercase-string&numeric-string&uppercase-string', $i); + assertType('decimal-int-string', $i); $s = ''; $s .= $f; @@ -66,13 +66,13 @@ function concatAssignEmptyString(int $i, float $f) { */ function integerRangeToString($positive, $negative) { - assertType('lowercase-string&numeric-string&uppercase-string', (string) $positive); - assertType('lowercase-string&numeric-string&uppercase-string', (string) $negative); + assertType('decimal-int-string', (string) $positive); + assertType('decimal-int-string', (string) $negative); if ($positive !== 0) { - assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', (string) $positive); + assertType('decimal-int-string&non-falsy-string', (string) $positive); } if ($negative !== 0) { - assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', (string) $negative); + assertType('decimal-int-string&non-falsy-string', (string) $negative); } } diff --git a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php index fe5c5fdd529..f59f41bba7c 100644 --- a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php +++ b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php @@ -14,7 +14,7 @@ public function doFoo(string $s): void { assertType('decimal-int-string' ,$s); $a = [$s => 1]; - assertType('non-empty-array', $a); + assertType('non-empty-array', $a); assertType('bool', (bool) $s); diff --git a/tests/PHPStan/Analyser/nsrt/filter-var.php b/tests/PHPStan/Analyser/nsrt/filter-var.php index 34d23f9c8fd..8fc5a852c0f 100644 --- a/tests/PHPStan/Analyser/nsrt/filter-var.php +++ b/tests/PHPStan/Analyser/nsrt/filter-var.php @@ -158,7 +158,7 @@ public function scalars(bool $bool, float $float, int $int, string $string, int assertType("'17'", filter_var(17.0)); assertType("'17.1'", filter_var(17.1)); assertType("'1.0E-50'", filter_var(1e-50)); - assertType('lowercase-string&numeric-string&uppercase-string', filter_var($int)); + assertType('decimal-int-string', filter_var($int)); assertType("'0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'", filter_var($intRange)); assertType("'17'", filter_var(17)); assertType('string', filter_var($string)); diff --git a/tests/PHPStan/Analyser/nsrt/generics.php b/tests/PHPStan/Analyser/nsrt/generics.php index 77b9a470a6b..9a01501f53a 100644 --- a/tests/PHPStan/Analyser/nsrt/generics.php +++ b/tests/PHPStan/Analyser/nsrt/generics.php @@ -147,10 +147,10 @@ function f($a, $b) */ function testF($arrayOfInt, $callableOrNull) { - assertType('Closure(int): (lowercase-string&numeric-string&uppercase-string)', function (int $a): string { + assertType('Closure(int): decimal-int-string', function (int $a): string { return (string)$a; }); - assertType('array', f($arrayOfInt, function (int $a): string { + assertType('array', f($arrayOfInt, function (int $a): string { return (string)$a; })); assertType('Closure(mixed): string', function ($a): string { @@ -224,7 +224,7 @@ function testArrayMap(array $listOfIntegers) return (string) $int; }, $listOfIntegers); - assertType('array', $strings); + assertType('array', $strings); } /** diff --git a/tests/PHPStan/Analyser/nsrt/key-exists.php b/tests/PHPStan/Analyser/nsrt/key-exists.php index 67c42f6c14a..cf8cd1def45 100644 --- a/tests/PHPStan/Analyser/nsrt/key-exists.php +++ b/tests/PHPStan/Analyser/nsrt/key-exists.php @@ -49,16 +49,16 @@ public function doBar(array $a, array $b, array $c, int $key1, string $key2, int assertType('int', $key1); } if (key_exists($key2, $a)) { - assertType('lowercase-string&numeric-string&uppercase-string', $key2); + assertType('decimal-int-string', $key2); } if (key_exists($key3, $a)) { - assertType('int|(lowercase-string&numeric-string&uppercase-string)', $key3); + assertType('int|decimal-int-string', $key3); } if (key_exists($key4, $a)) { - assertType('(int|(lowercase-string&numeric-string&uppercase-string))', $key4); + assertType('(int|decimal-int-string)', $key4); } if (key_exists($key5, $a)) { - assertType('int|(lowercase-string&numeric-string&uppercase-string)', $key5); + assertType('int|decimal-int-string', $key5); } if (key_exists($key1, $b)) { diff --git a/tests/PHPStan/Analyser/nsrt/range-to-string.php b/tests/PHPStan/Analyser/nsrt/range-to-string.php index 49bb179309d..4e4a7a844d1 100644 --- a/tests/PHPStan/Analyser/nsrt/range-to-string.php +++ b/tests/PHPStan/Analyser/nsrt/range-to-string.php @@ -17,6 +17,6 @@ public function sayHello($i, $ii, $maxlong, $toolong): void assertType("'10'|'5'|'6'|'7'|'8'|'9'", (string) $i); assertType("'-1'|'-10'|'-2'|'-3'|'-4'|'-5'|'-6'|'-7'|'-8'|'-9'|'0'|'1'|'10'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'", (string) $ii); assertType("'0'|'1'|'10'|'100'|'101'|'102'|'103'|'104'|'105'|'106'|'107'|'108'|'109'|'11'|'110'|'111'|'112'|'113'|'114'|'115'|'116'|'117'|'118'|'119'|'12'|'120'|'121'|'122'|'123'|'124'|'125'|'126'|'127'|'128'|'13'|'14'|'15'|'16'|'17'|'18'|'19'|'2'|'20'|'21'|'22'|'23'|'24'|'25'|'26'|'27'|'28'|'29'|'3'|'30'|'31'|'32'|'33'|'34'|'35'|'36'|'37'|'38'|'39'|'4'|'40'|'41'|'42'|'43'|'44'|'45'|'46'|'47'|'48'|'49'|'5'|'50'|'51'|'52'|'53'|'54'|'55'|'56'|'57'|'58'|'59'|'6'|'60'|'61'|'62'|'63'|'64'|'65'|'66'|'67'|'68'|'69'|'7'|'70'|'71'|'72'|'73'|'74'|'75'|'76'|'77'|'78'|'79'|'8'|'80'|'81'|'82'|'83'|'84'|'85'|'86'|'87'|'88'|'89'|'9'|'90'|'91'|'92'|'93'|'94'|'95'|'96'|'97'|'98'|'99'", (string) $maxlong); - assertType("lowercase-string&numeric-string&uppercase-string", (string) $toolong); + assertType('decimal-int-string', (string) $toolong); } } diff --git a/tests/PHPStan/Analyser/nsrt/set-type-type-specifying.php b/tests/PHPStan/Analyser/nsrt/set-type-type-specifying.php index ef0f894c639..f90e7dca5a4 100644 --- a/tests/PHPStan/Analyser/nsrt/set-type-type-specifying.php +++ b/tests/PHPStan/Analyser/nsrt/set-type-type-specifying.php @@ -11,7 +11,7 @@ function doString(string $s, int $i, float $f, array $a, object $o) assertType('string', $s); settype($i, 'string'); - assertType('lowercase-string&numeric-string&uppercase-string', $i); + assertType('decimal-int-string', $i); settype($f, 'string'); assertType('numeric-string&uppercase-string', $f); diff --git a/tests/PHPStan/Analyser/nsrt/strval.php b/tests/PHPStan/Analyser/nsrt/strval.php index b28f31549b8..11dd7c00829 100644 --- a/tests/PHPStan/Analyser/nsrt/strval.php +++ b/tests/PHPStan/Analyser/nsrt/strval.php @@ -17,9 +17,9 @@ function strvalTest(string $string, string $class): void assertType('\'1\'', strval(true)); assertType('\'\'|\'1\'', strval(rand(0, 1) === 0)); assertType('\'42\'', strval(42)); - assertType('lowercase-string&numeric-string&uppercase-string', strval(rand())); + assertType('decimal-int-string', strval(rand())); assertType('numeric-string&uppercase-string', strval(rand() * 0.1)); - assertType('lowercase-string&numeric-string&uppercase-string', strval(strval(rand()))); + assertType('decimal-int-string', strval(strval(rand()))); assertType('class-string', strval($class)); assertType('string', strval(new \Exception())); assertType('*ERROR*', strval(new \stdClass())); diff --git a/tests/PHPStan/Rules/Generics/data/bug-6301.php b/tests/PHPStan/Rules/Generics/data/bug-6301.php index 8d325982942..e31516c65c3 100644 --- a/tests/PHPStan/Rules/Generics/data/bug-6301.php +++ b/tests/PHPStan/Rules/Generics/data/bug-6301.php @@ -22,7 +22,7 @@ public function str($s) * @param literal-string $literalString */ public function foo(int $i, $nonEmpty, $numericString, $literalString):void { - assertType('lowercase-string&numeric-string&uppercase-string', $this->str((string) $i)); + assertType('decimal-int-string', $this->str((string) $i)); assertType('non-empty-string', $this->str($nonEmpty)); assertType('numeric-string', $this->str($numericString)); assertType('literal-string', $this->str($literalString)); From 2b79f6cb46ffdd5a7c516aad9e1cc1bf556d3882 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 2 Jun 2026 18:36:07 +0000 Subject: [PATCH 2/4] Document why decimal-int-string toArrayKey respects the casting toggle Explains, in response to review, that the toggle gate mirrors StringType::toArrayKey() and is required to avoid the bug #4671 false positive (isset on a string-keyed array with a (string) $int offset). Co-Authored-By: Claude Opus 4.8 --- src/Type/IntersectionType.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 7340be0b253..da15ee97aac 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -1554,6 +1554,10 @@ public function toArray(): Type public function toArrayKey(): Type { + // A decimal-int-string is always coerced to an integer key by PHP, so the precise + // key type is int. But mirroring StringType::toArrayKey(), we only narrow to int + // under 'prevent'; by default we keep the string key to avoid false positives like + // isset($stringKeyedArray[(string) $int]) reporting the offset can never exist (bug #4671). if ($this->isDecimalIntegerString()->yes()) { if (ReportUnsafeArrayStringKeyCastingToggle::getLevel() !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { return $this; From 35466453ab2490bee167c000432ea2b543b8b2e6 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 3 Jun 2026 05:21:40 +0000 Subject: [PATCH 3/4] Stop special-casing decimal-int-string in IntersectionType::toArrayKey() The toArrayKey() coercion of decimal-int-string keys is an unrelated concern tracked separately in phpstan/phpstan#14758, so it is dropped from this PR. Removing the special case lets decimal-int-string fall through to the numeric-string branch (it is a numeric-string), yielding int|decimal-int-string just like any other numeric string. This keeps the bug #4671 isset regression test green without implementing the toggle-gated precision behavior, which belongs in #14758. Co-Authored-By: Claude Opus 4.8 --- src/Type/IntersectionType.php | 13 ------------- tests/PHPStan/Analyser/nsrt/decimal-int-string.php | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index da15ee97aac..abd7eb10131 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -2,7 +2,6 @@ namespace PHPStan\Type; -use PHPStan\DependencyInjection\ReportUnsafeArrayStringKeyCastingToggle; use PHPStan\Internal\CombinationsHelper; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; @@ -1554,18 +1553,6 @@ public function toArray(): Type public function toArrayKey(): Type { - // A decimal-int-string is always coerced to an integer key by PHP, so the precise - // key type is int. But mirroring StringType::toArrayKey(), we only narrow to int - // under 'prevent'; by default we keep the string key to avoid false positives like - // isset($stringKeyedArray[(string) $int]) reporting the offset can never exist (bug #4671). - if ($this->isDecimalIntegerString()->yes()) { - if (ReportUnsafeArrayStringKeyCastingToggle::getLevel() !== ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { - return $this; - } - - return new IntegerType(); - } - if ($this->isNumericString()->yes()) { return TypeCombinator::union( new IntegerType(), diff --git a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php index f59f41bba7c..b3be8fb66c6 100644 --- a/tests/PHPStan/Analyser/nsrt/decimal-int-string.php +++ b/tests/PHPStan/Analyser/nsrt/decimal-int-string.php @@ -14,7 +14,7 @@ public function doFoo(string $s): void { assertType('decimal-int-string' ,$s); $a = [$s => 1]; - assertType('non-empty-array', $a); + assertType('non-empty-array', $a); assertType('bool', (bool) $s); From 7b146222ba11a400f73083174ccc5c6156c2fa32 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 3 Jun 2026 10:20:34 +0200 Subject: [PATCH 4/4] Update IntersectionType.php --- src/Type/IntersectionType.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index abd7eb10131..98d07e1ad0b 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -1553,6 +1553,10 @@ public function toArray(): Type public function toArrayKey(): Type { + if ($this->isDecimalIntegerString()->yes()) { + return new IntegerType(); + } + if ($this->isNumericString()->yes()) { return TypeCombinator::union( new IntegerType(),