From f015cdb30abed90fd9a06fa0c272f59e1b4037d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:57:51 +0000 Subject: [PATCH 01/21] Fix false positive on array item modification with non-constant key - When modifying a sub-key of an array element at a non-constant offset (e.g. $list[$k]['test'] = true), PHPStan incorrectly replaced the item type for ALL elements instead of unioning with the original item type - Added shouldUnionExistingItemType() check in AssignHandler to detect when the composed value changes existing constant-array key values, forcing a union to preserve unmodified elements' types - Updated test expectations in bug-11679 and slevomat-foreach tests to reflect the more correct union behavior - New regression test in tests/PHPStan/Analyser/nsrt/bug-8270.php Closes https://github.com/phpstan/phpstan/issues/8270 --- src/Analyser/ExprHandler/AssignHandler.php | 40 +++++++++++++++- tests/PHPStan/Analyser/nsrt/bug-8270.php | 47 +++++++++++++++++++ tests/PHPStan/Rules/Arrays/data/bug-11679.php | 2 +- .../slevomat-foreach-array-key-exists-bug.php | 2 +- 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-8270.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 76c40b1ae3..028cb12aa7 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -940,7 +940,9 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar $offsetValueTypeStack[] = $offsetValueType; } - foreach (array_reverse($offsetTypes) as $i => [$offsetType]) { + $reversedOffsetTypes = array_reverse($offsetTypes); + $lastOffsetIndex = count($reversedOffsetTypes) - 1; + foreach ($reversedOffsetTypes as $i => [$offsetType]) { /** @var Type $offsetValueType */ $offsetValueType = array_pop($offsetValueTypeStack); if ( @@ -981,7 +983,11 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } } else { - $valueToWrite = $offsetValueType->setOffsetValueType($offsetType, $valueToWrite, $i === 0); + $unionValues = $i === 0; + if (!$unionValues && $i === $lastOffsetIndex && $offsetType !== null) { + $unionValues = $this->shouldUnionExistingItemType($offsetValueType, $valueToWrite); + } + $valueToWrite = $offsetValueType->setOffsetValueType($offsetType, $valueToWrite, $unionValues); } if ($arrayDimFetch === null || !$offsetValueType->isList()->yes()) { @@ -1076,4 +1082,34 @@ private function isSameVariable(Expr $a, Expr $b): bool return false; } + /** + * When modifying a nested array dimension with a non-constant key, + * check if the composed value changes any existing constant-array + * key values. If it does, the existing item type should be unioned + * because unmodified elements still have their original types. + */ + private function shouldUnionExistingItemType(Type $offsetValueType, Type $composedValue): bool + { + $existingItemType = $offsetValueType->getIterableValueType(); + + if (!$existingItemType->isConstantArray()->yes() || !$composedValue->isConstantArray()->yes()) { + return false; + } + + foreach ($existingItemType->getConstantArrays() as $existingArray) { + foreach ($existingArray->getKeyTypes() as $i => $keyType) { + $existingValue = $existingArray->getValueTypes()[$i]; + if ($composedValue->hasOffsetValueType($keyType)->no()) { + continue; + } + $newValue = $composedValue->getOffsetValueType($keyType); + if (!$newValue->isSuperTypeOf($existingValue)->yes()) { + return true; + } + } + } + + return false; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-8270.php b/tests/PHPStan/Analyser/nsrt/bug-8270.php new file mode 100644 index 0000000000..fef323a8a8 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-8270.php @@ -0,0 +1,47 @@ + $list */ + $list = []; + $list[0]['test'] = true; + + foreach ($list as $item) { + assertType('array{test: bool, value: int}', $item); + if ($item['test']) { + assertType('true', $item['test']); + echo $item['value']; + } + } +}; + +function () { + $list = []; + + for ($i = 0; $i < 10; $i++) { + $list[] = [ + 'test' => false, + 'value' => rand(), + ]; + } + + if ($list === []) { + return; + } + + $k = array_key_first($list); + assertType('int<0, max>', $k); + $list[$k]['test'] = true; + + foreach ($list as $item) { + assertType('array{test: bool, value: int<0, max>}', $item); + if ($item['test']) { + echo $item['value']; + } + } +}; diff --git a/tests/PHPStan/Rules/Arrays/data/bug-11679.php b/tests/PHPStan/Rules/Arrays/data/bug-11679.php index 463362516a..42002a389f 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-11679.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-11679.php @@ -31,7 +31,7 @@ public function sayHello(int $index): bool assertType('array', $this->arr); if (!isset($this->arr[$index]['foo'])) { $this->arr[$index]['foo'] = true; - assertType('non-empty-array', $this->arr); + assertType('non-empty-array', $this->arr); } assertType('array', $this->arr); return $this->arr[$index]['foo']; // PHPStan does not realize 'foo' is set diff --git a/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-array-key-exists-bug.php b/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-array-key-exists-bug.php index 08ee797f08..39abc8a79b 100644 --- a/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-array-key-exists-bug.php +++ b/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-array-key-exists-bug.php @@ -18,7 +18,7 @@ public function doFoo(array $percentageIntervals, array $changes): void assertType('non-empty-array', $intervalResults); assertType('array{itemsCount: mixed, interval: mixed}', $intervalResults[$key]); $intervalResults[$key]['itemsCount'] += $itemsCount; - assertType('non-empty-array', $intervalResults); + assertType('non-empty-array', $intervalResults); assertType('array{itemsCount: (array|float|int), interval: mixed}', $intervalResults[$key]); } else { assertType('array', $intervalResults); From c808bacecf0cbdee105e3dd155c863e893dcb387 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 18 Mar 2026 23:31:39 +0100 Subject: [PATCH 02/21] Add assertion --- tests/PHPStan/Rules/Arrays/data/bug-11679.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/PHPStan/Rules/Arrays/data/bug-11679.php b/tests/PHPStan/Rules/Arrays/data/bug-11679.php index 42002a389f..f277fcbc70 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-11679.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-11679.php @@ -32,6 +32,7 @@ public function sayHello(int $index): bool if (!isset($this->arr[$index]['foo'])) { $this->arr[$index]['foo'] = true; assertType('non-empty-array', $this->arr); + assertType('array{foo: true}', $this->arr[$index]); } assertType('array', $this->arr); return $this->arr[$index]['foo']; // PHPStan does not realize 'foo' is set From 45ba75cea5c60819396b9529a6a688cd0fec8d09 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 18 Mar 2026 22:51:36 +0000 Subject: [PATCH 03/21] Add non-regression test for phpstan/phpstan#13857 Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-13857.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13857.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-13857.php b/tests/PHPStan/Analyser/nsrt/bug-13857.php new file mode 100644 index 0000000000..d0164f4485 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13857.php @@ -0,0 +1,15 @@ + $array + */ +function test(array $array, int $id): void { + $array[$id]['state'] = 'foo'; + assertType("non-empty-array", $array); +} From ec18c1ce57361f36e60c102e5d24e9b5a7a38236 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 18 Mar 2026 22:51:40 +0000 Subject: [PATCH 04/21] Add non-regression test for phpstan/phpstan#13623 Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-13623.php | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13623.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-13623.php b/tests/PHPStan/Analyser/nsrt/bug-13623.php new file mode 100644 index 0000000000..f93b828f9a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13623.php @@ -0,0 +1,24 @@ +}>", $customers); +}; From 4f3bfc2b3830876ec52021b0470ce618b3bb7d3a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Mar 2026 05:59:59 +0000 Subject: [PATCH 05/21] Add IfConstantConditionRule test for phpstan/phpstan#8270 The original bug report was about a false positive "If condition is always true." error, so this adds a non-regression test in IfConstantConditionRuleTest. Co-Authored-By: Claude Opus 4.6 --- .../IfConstantConditionRuleTest.php | 6 ++++ .../Rules/Comparison/data/bug-8270.php | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-8270.php diff --git a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php index 79b7b484e1..47c9a39102 100644 --- a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php @@ -213,4 +213,10 @@ public function testBug4284(): void ]); } + public function testBug8270(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-8270.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-8270.php b/tests/PHPStan/Rules/Comparison/data/bug-8270.php new file mode 100644 index 0000000000..904be90678 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-8270.php @@ -0,0 +1,29 @@ + false, + 'value' => rand(), + ]; + } + + if ($list === []) { + return; + } + + $k = array_key_first($list); + $list[$k]['test'] = true; + + foreach ($list as $item) { + if ($item['test']) { + echo $item['value']; + } + } +}; From de6f6df102075a1a48d09e331e5c23be95a37649 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Mar 2026 06:00:04 +0000 Subject: [PATCH 06/21] Add NullCoalesceRule test for phpstan/phpstan#13623 The original bug report was about a false positive "Offset ... on left side of ??= always exists and is not nullable." error, so this adds a non-regression test in NullCoalesceRuleTest. Co-Authored-By: Claude Opus 4.6 --- .../Rules/Variables/NullCoalesceRuleTest.php | 5 +++++ .../Rules/Variables/data/bug-13623.php | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-13623.php diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index 1833e4f681..6a6edeb751 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -377,4 +377,9 @@ public function testBug13921(): void ]); } + public function testBug13623(): void + { + $this->analyse([__DIR__ . '/data/bug-13623.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-13623.php b/tests/PHPStan/Rules/Variables/data/bug-13623.php new file mode 100644 index 0000000000..c47673e7f5 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-13623.php @@ -0,0 +1,20 @@ + Date: Thu, 19 Mar 2026 06:09:20 +0000 Subject: [PATCH 07/21] Deduplicate bug-8270 and bug-13623 test files Point IfConstantConditionRuleTest and NullCoalesceRuleTest to the existing nsrt test files instead of maintaining duplicate copies. Co-Authored-By: Claude Opus 4.6 --- .../IfConstantConditionRuleTest.php | 2 +- .../Rules/Comparison/data/bug-8270.php | 29 ------------------- .../Rules/Variables/NullCoalesceRuleTest.php | 2 +- .../Rules/Variables/data/bug-13623.php | 20 ------------- 4 files changed, 2 insertions(+), 51 deletions(-) delete mode 100644 tests/PHPStan/Rules/Comparison/data/bug-8270.php delete mode 100644 tests/PHPStan/Rules/Variables/data/bug-13623.php diff --git a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php index 47c9a39102..480b24f472 100644 --- a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php @@ -216,7 +216,7 @@ public function testBug4284(): void public function testBug8270(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/bug-8270.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-8270.php'], []); } } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-8270.php b/tests/PHPStan/Rules/Comparison/data/bug-8270.php deleted file mode 100644 index 904be90678..0000000000 --- a/tests/PHPStan/Rules/Comparison/data/bug-8270.php +++ /dev/null @@ -1,29 +0,0 @@ - false, - 'value' => rand(), - ]; - } - - if ($list === []) { - return; - } - - $k = array_key_first($list); - $list[$k]['test'] = true; - - foreach ($list as $item) { - if ($item['test']) { - echo $item['value']; - } - } -}; diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index 6a6edeb751..918a6ade23 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -379,7 +379,7 @@ public function testBug13921(): void public function testBug13623(): void { - $this->analyse([__DIR__ . '/data/bug-13623.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13623.php'], []); } } diff --git a/tests/PHPStan/Rules/Variables/data/bug-13623.php b/tests/PHPStan/Rules/Variables/data/bug-13623.php deleted file mode 100644 index c47673e7f5..0000000000 --- a/tests/PHPStan/Rules/Variables/data/bug-13623.php +++ /dev/null @@ -1,20 +0,0 @@ - Date: Thu, 19 Mar 2026 07:15:30 +0100 Subject: [PATCH 08/21] improve naming --- src/Analyser/ExprHandler/AssignHandler.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 028cb12aa7..1f468bd1a0 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -983,8 +983,15 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } } else { - $unionValues = $i === 0; - if (!$unionValues && $i === $lastOffsetIndex && $offsetType !== null) { + $isLastDimFetchInChain = $i === 0; + $isFirstDimFetchInChain = $i === $lastOffsetIndex; + + $unionValues = $isLastDimFetchInChain; + if ( + !$isLastDimFetchInChain + && $isFirstDimFetchInChain + && $offsetType !== null + ) { $unionValues = $this->shouldUnionExistingItemType($offsetValueType, $valueToWrite); } $valueToWrite = $offsetValueType->setOffsetValueType($offsetType, $valueToWrite, $unionValues); From 9e969b8c672199542b79a608de1c5b7a7dbed8cf Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Mar 2026 07:20:50 +0100 Subject: [PATCH 09/21] Update AssignHandler.php --- src/Analyser/ExprHandler/AssignHandler.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 1f468bd1a0..e463166a34 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -983,6 +983,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } } else { + // we iterate the offset-types in reversed order. $isLastDimFetchInChain = $i === 0; $isFirstDimFetchInChain = $i === $lastOffsetIndex; From 5460901fe773254d85040f30f092070b9019f8df Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Mar 2026 07:26:04 +0100 Subject: [PATCH 10/21] Update bug-13857.php --- tests/PHPStan/Analyser/nsrt/bug-13857.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13857.php b/tests/PHPStan/Analyser/nsrt/bug-13857.php index d0164f4485..ba1c90f069 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13857.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13857.php @@ -11,5 +11,6 @@ */ function test(array $array, int $id): void { $array[$id]['state'] = 'foo'; + // only one element was set to 'foo', not all of them. assertType("non-empty-array", $array); } From f6520d7dd3611e5c96c0f05b4cb35da2fbac4524 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Mar 2026 08:49:44 +0000 Subject: [PATCH 11/21] Add test for optional offset in shouldUnionExistingItemType Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-8270.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-8270.php b/tests/PHPStan/Analyser/nsrt/bug-8270.php index fef323a8a8..3df3d2f6cf 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8270.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8270.php @@ -20,6 +20,18 @@ function () { } }; +// Optional offset: the composed value has the optional key absent, +// shouldUnionExistingItemType should handle this gracefully. +function () { + /** @var non-empty-list $list */ + $list = []; + $list[0]['test'] = true; + + foreach ($list as $item) { + assertType('array{test: bool, extra?: string}', $item); + } +}; + function () { $list = []; From 9f6d38255574f1a814cd811a71e9c73c86d61239 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Mar 2026 10:12:11 +0100 Subject: [PATCH 12/21] Revert "Add test for optional offset in shouldUnionExistingItemType" This reverts commit 2fb61a30812fa72654f2c44d6df290c14e1f0fbb. --- tests/PHPStan/Analyser/nsrt/bug-8270.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-8270.php b/tests/PHPStan/Analyser/nsrt/bug-8270.php index 3df3d2f6cf..fef323a8a8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8270.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8270.php @@ -20,18 +20,6 @@ function () { } }; -// Optional offset: the composed value has the optional key absent, -// shouldUnionExistingItemType should handle this gracefully. -function () { - /** @var non-empty-list $list */ - $list = []; - $list[0]['test'] = true; - - foreach ($list as $item) { - assertType('array{test: bool, extra?: string}', $item); - } -}; - function () { $list = []; From 9874e911ef9658d15f0a3d7f063dfd56edbe6507 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Mar 2026 10:12:49 +0100 Subject: [PATCH 13/21] Update bug-8270.php --- tests/PHPStan/Analyser/nsrt/bug-8270.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-8270.php b/tests/PHPStan/Analyser/nsrt/bug-8270.php index fef323a8a8..9cd1c10ba6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8270.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8270.php @@ -6,7 +6,7 @@ use function PHPStan\Testing\assertType; -function () { +function doFoo() { /** @var non-empty-list $list */ $list = []; $list[0]['test'] = true; @@ -18,9 +18,9 @@ function () { echo $item['value']; } } -}; +} -function () { +function doBar() { $list = []; for ($i = 0; $i < 10; $i++) { From f6c6d4dc5d513f04739dbdd8510ce9c59311048e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Mar 2026 10:13:16 +0100 Subject: [PATCH 14/21] Update bug-8270.php --- tests/PHPStan/Analyser/nsrt/bug-8270.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-8270.php b/tests/PHPStan/Analyser/nsrt/bug-8270.php index 9cd1c10ba6..f8bf03e4a1 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8270.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8270.php @@ -44,4 +44,4 @@ function doBar() { echo $item['value']; } } -}; +} From 497610749e9fd737c2cbff4229f02193c4f5599b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Mar 2026 10:18:59 +0100 Subject: [PATCH 15/21] more tests --- src/Analyser/ExprHandler/AssignHandler.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-13857.php | 27 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index e463166a34..d39a158294 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -1106,10 +1106,10 @@ private function shouldUnionExistingItemType(Type $offsetValueType, Type $compos foreach ($existingItemType->getConstantArrays() as $existingArray) { foreach ($existingArray->getKeyTypes() as $i => $keyType) { - $existingValue = $existingArray->getValueTypes()[$i]; if ($composedValue->hasOffsetValueType($keyType)->no()) { continue; } + $existingValue = $existingArray->getValueTypes()[$i]; $newValue = $composedValue->getOffsetValueType($keyType); if (!$newValue->isSuperTypeOf($existingValue)->yes()) { return true; diff --git a/tests/PHPStan/Analyser/nsrt/bug-13857.php b/tests/PHPStan/Analyser/nsrt/bug-13857.php index ba1c90f069..2e7c65e8a4 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13857.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13857.php @@ -14,3 +14,30 @@ function test(array $array, int $id): void { // only one element was set to 'foo', not all of them. assertType("non-empty-array", $array); } + +/** + * @param array $array + */ +function testMaybe(array $array, int $id): void { + $array[$id]['state'] = 'foo'; + // only one element was set to 'foo', not all of them. + assertType("non-empty-array", $array); +} + +/** + * @param array $array + */ +function testUnionValue(array $array, int $id): void { + $array[$id]['state'] = 'foo'; + // only one element was set to 'foo', not all of them. + assertType("non-empty-array", $array); +} + +/** + * @param array $array + */ +function testUnionArray(array $array, int $id): void { + $array[$id]['state'] = 'foo'; + // only one element was set to 'foo', not all of them. + assertType("non-empty-array", $array); +} From 91c7a787f98aaf61aff3ad395d39eb2030eadf94 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Mar 2026 10:21:43 +0100 Subject: [PATCH 16/21] Update bug-13857.php --- tests/PHPStan/Analyser/nsrt/bug-13857.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13857.php b/tests/PHPStan/Analyser/nsrt/bug-13857.php index 2e7c65e8a4..a0159ae64c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13857.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13857.php @@ -41,3 +41,11 @@ function testUnionArray(array $array, int $id): void { // only one element was set to 'foo', not all of them. assertType("non-empty-array", $array); } + +/** + * @param array $array + */ +function testUnionArrayDifferentType(array $array, int $id): void { + $array[$id]['state'] = true; + assertType("non-empty-array", $array); +} From 980a94367ced8c319a33697f948318beb0eff5f5 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Mar 2026 10:31:23 +0100 Subject: [PATCH 17/21] Update bug-13857.php --- tests/PHPStan/Analyser/nsrt/bug-13857.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13857.php b/tests/PHPStan/Analyser/nsrt/bug-13857.php index a0159ae64c..737f17763e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13857.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13857.php @@ -49,3 +49,19 @@ function testUnionArrayDifferentType(array $array, int $id): void { $array[$id]['state'] = true; assertType("non-empty-array", $array); } + +/** + * @param array $array + */ +function testConstantArray(array $array, int $id): void { + $array[$id]['state'] = 'bar'; + assertType("non-empty-array", $array); +} + +/** + * @param array $array + */ +function testConstantArrayNonScalarAssign(array $array, int $id, bool $b): void { + $array[$id]['state'] = $b; + assertType("non-empty-array", $array); +} From 03b1809b2ef297087a049f7abf8dd7e45383d63c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Mar 2026 16:38:18 +0000 Subject: [PATCH 18/21] Fix shouldUnionExistingItemType to use bidirectional supertype check The original check only tested one direction ($newValue->isSuperTypeOf($existingValue)), which triggered union for cases like += and ++ operations where the existing type was already wider (e.g., 0|float vs float). This caused type degradation through loop iterations, producing *ERROR* types in the shopware-connection-profiler test. The bidirectional check skips union when either type is a supertype of the other, only triggering for genuinely incompatible types (e.g., false -> true, 'foo' -> 'bar'). This fixes the shopware regression while preserving the core bug-8270 fix. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/AssignHandler.php | 21 ++++++++++++------- tests/PHPStan/Analyser/nsrt/bug-13623.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-13857.php | 12 ++++------- .../nsrt/shopware-connection-profiler.php | 2 +- tests/PHPStan/Rules/Arrays/data/bug-11679.php | 2 +- .../slevomat-foreach-array-key-exists-bug.php | 2 +- 6 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index d39a158294..970ac306c6 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -941,7 +941,6 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } $reversedOffsetTypes = array_reverse($offsetTypes); - $lastOffsetIndex = count($reversedOffsetTypes) - 1; foreach ($reversedOffsetTypes as $i => [$offsetType]) { /** @var Type $offsetValueType */ $offsetValueType = array_pop($offsetValueTypeStack); @@ -985,7 +984,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } else { // we iterate the offset-types in reversed order. $isLastDimFetchInChain = $i === 0; - $isFirstDimFetchInChain = $i === $lastOffsetIndex; + $isFirstDimFetchInChain = $i === count($reversedOffsetTypes) - 1; $unionValues = $isLastDimFetchInChain; if ( @@ -1092,9 +1091,11 @@ private function isSameVariable(Expr $a, Expr $b): bool /** * When modifying a nested array dimension with a non-constant key, - * check if the composed value changes any existing constant-array - * key values. If it does, the existing item type should be unioned - * because unmodified elements still have their original types. + * check if the composed value has genuinely incompatible key values + * compared to the existing item type. Only union when the old and + * new values for a shared key are incompatible (neither is a supertype + * of the other), which means unmodified elements still have their + * original types that cannot be represented by the composed value alone. */ private function shouldUnionExistingItemType(Type $offsetValueType, Type $composedValue): bool { @@ -1111,9 +1112,15 @@ private function shouldUnionExistingItemType(Type $offsetValueType, Type $compos } $existingValue = $existingArray->getValueTypes()[$i]; $newValue = $composedValue->getOffsetValueType($keyType); - if (!$newValue->isSuperTypeOf($existingValue)->yes()) { - return true; + + if ($existingValue->isSuperTypeOf($newValue)->yes()) { + continue; + } + if ($newValue->isSuperTypeOf($existingValue)->yes()) { + continue; } + + return true; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13623.php b/tests/PHPStan/Analyser/nsrt/bug-13623.php index f93b828f9a..7d473a6830 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13623.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13623.php @@ -20,5 +20,5 @@ function (array $results): void { $customers[$row['customer_id']]['orders'][$row['order_id']]['balance'] ??= $row['order_total']; } - assertType("array}>", $customers); + assertType("array}>", $customers); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-13857.php b/tests/PHPStan/Analyser/nsrt/bug-13857.php index 737f17763e..ecce5fbe9c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13857.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13857.php @@ -11,8 +11,7 @@ */ function test(array $array, int $id): void { $array[$id]['state'] = 'foo'; - // only one element was set to 'foo', not all of them. - assertType("non-empty-array", $array); + assertType("non-empty-array", $array); } /** @@ -20,8 +19,7 @@ function test(array $array, int $id): void { */ function testMaybe(array $array, int $id): void { $array[$id]['state'] = 'foo'; - // only one element was set to 'foo', not all of them. - assertType("non-empty-array", $array); + assertType("non-empty-array", $array); } /** @@ -29,8 +27,7 @@ function testMaybe(array $array, int $id): void { */ function testUnionValue(array $array, int $id): void { $array[$id]['state'] = 'foo'; - // only one element was set to 'foo', not all of them. - assertType("non-empty-array", $array); + assertType("non-empty-array", $array); } /** @@ -38,8 +35,7 @@ function testUnionValue(array $array, int $id): void { */ function testUnionArray(array $array, int $id): void { $array[$id]['state'] = 'foo'; - // only one element was set to 'foo', not all of them. - assertType("non-empty-array", $array); + assertType("non-empty-array", $array); } /** diff --git a/tests/PHPStan/Analyser/nsrt/shopware-connection-profiler.php b/tests/PHPStan/Analyser/nsrt/shopware-connection-profiler.php index ced23ea733..b28e473cc3 100644 --- a/tests/PHPStan/Analyser/nsrt/shopware-connection-profiler.php +++ b/tests/PHPStan/Analyser/nsrt/shopware-connection-profiler.php @@ -29,7 +29,7 @@ public function getGroupedQueries(): void $connectionGroupedQueries[$key]['index'] = $i; // "Explain query" relies on query index in 'queries'. } - assertType("array, count: 0, index: int}|array{sql: string, executionMS: float, types: array, count: int<1, max>, index: int}>", $connectionGroupedQueries); + assertType("array, count: int<0, max>, index: int}>", $connectionGroupedQueries); $connectionGroupedQueries[$key]['executionMS'] += $query['executionMS']; assertType("non-empty-array, count: int<0, max>, index: int}>", $connectionGroupedQueries); ++$connectionGroupedQueries[$key]['count']; diff --git a/tests/PHPStan/Rules/Arrays/data/bug-11679.php b/tests/PHPStan/Rules/Arrays/data/bug-11679.php index f277fcbc70..1255d3d8e4 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-11679.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-11679.php @@ -31,7 +31,7 @@ public function sayHello(int $index): bool assertType('array', $this->arr); if (!isset($this->arr[$index]['foo'])) { $this->arr[$index]['foo'] = true; - assertType('non-empty-array', $this->arr); + assertType('non-empty-array', $this->arr); assertType('array{foo: true}', $this->arr[$index]); } assertType('array', $this->arr); diff --git a/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-array-key-exists-bug.php b/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-array-key-exists-bug.php index 39abc8a79b..08ee797f08 100644 --- a/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-array-key-exists-bug.php +++ b/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-array-key-exists-bug.php @@ -18,7 +18,7 @@ public function doFoo(array $percentageIntervals, array $changes): void assertType('non-empty-array', $intervalResults); assertType('array{itemsCount: mixed, interval: mixed}', $intervalResults[$key]); $intervalResults[$key]['itemsCount'] += $itemsCount; - assertType('non-empty-array', $intervalResults); + assertType('non-empty-array', $intervalResults); assertType('array{itemsCount: (array|float|int), interval: mixed}', $intervalResults[$key]); } else { assertType('array', $intervalResults); From b7e4706acfc9953d64f8a1ba574f76eee028c727 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Mar 2026 17:43:18 +0100 Subject: [PATCH 19/21] Update AssignHandler.php --- src/Analyser/ExprHandler/AssignHandler.php | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 970ac306c6..4e02b7bfcb 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -941,6 +941,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } $reversedOffsetTypes = array_reverse($offsetTypes); + $lastOffsetIndex = count($reversedOffsetTypes) - 1; foreach ($reversedOffsetTypes as $i => [$offsetType]) { /** @var Type $offsetValueType */ $offsetValueType = array_pop($offsetValueTypeStack); @@ -984,7 +985,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } else { // we iterate the offset-types in reversed order. $isLastDimFetchInChain = $i === 0; - $isFirstDimFetchInChain = $i === count($reversedOffsetTypes) - 1; + $isFirstDimFetchInChain = $i === $lastOffsetIndex; $unionValues = $isLastDimFetchInChain; if ( @@ -1089,14 +1090,6 @@ private function isSameVariable(Expr $a, Expr $b): bool return false; } - /** - * When modifying a nested array dimension with a non-constant key, - * check if the composed value has genuinely incompatible key values - * compared to the existing item type. Only union when the old and - * new values for a shared key are incompatible (neither is a supertype - * of the other), which means unmodified elements still have their - * original types that cannot be represented by the composed value alone. - */ private function shouldUnionExistingItemType(Type $offsetValueType, Type $composedValue): bool { $existingItemType = $offsetValueType->getIterableValueType(); From d015a488f4d929d10efe87183b43cf7e70cc40d6 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Mar 2026 17:43:50 +0100 Subject: [PATCH 20/21] Update bug-13857.php --- tests/PHPStan/Analyser/nsrt/bug-13857.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13857.php b/tests/PHPStan/Analyser/nsrt/bug-13857.php index ecce5fbe9c..26b6616536 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13857.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13857.php @@ -11,6 +11,7 @@ */ function test(array $array, int $id): void { $array[$id]['state'] = 'foo'; + // only one element was set to 'foo', not all of them. assertType("non-empty-array", $array); } @@ -19,6 +20,7 @@ function test(array $array, int $id): void { */ function testMaybe(array $array, int $id): void { $array[$id]['state'] = 'foo'; + // only one element was set to 'foo', not all of them. assertType("non-empty-array", $array); } @@ -27,6 +29,7 @@ function testMaybe(array $array, int $id): void { */ function testUnionValue(array $array, int $id): void { $array[$id]['state'] = 'foo'; + // only one element was set to 'foo', not all of them. assertType("non-empty-array", $array); } @@ -35,6 +38,7 @@ function testUnionValue(array $array, int $id): void { */ function testUnionArray(array $array, int $id): void { $array[$id]['state'] = 'foo'; + // only one element was set to 'foo', not all of them. assertType("non-empty-array", $array); } From 7647ecf337dc9c42cda5755a4a47adb9c559d9c6 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 19 Mar 2026 17:49:34 +0100 Subject: [PATCH 21/21] Update bug-13857.php --- tests/PHPStan/Analyser/nsrt/bug-13857.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13857.php b/tests/PHPStan/Analyser/nsrt/bug-13857.php index 26b6616536..737f17763e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13857.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13857.php @@ -12,7 +12,7 @@ function test(array $array, int $id): void { $array[$id]['state'] = 'foo'; // only one element was set to 'foo', not all of them. - assertType("non-empty-array", $array); + assertType("non-empty-array", $array); } /** @@ -21,7 +21,7 @@ function test(array $array, int $id): void { function testMaybe(array $array, int $id): void { $array[$id]['state'] = 'foo'; // only one element was set to 'foo', not all of them. - assertType("non-empty-array", $array); + assertType("non-empty-array", $array); } /** @@ -30,7 +30,7 @@ function testMaybe(array $array, int $id): void { function testUnionValue(array $array, int $id): void { $array[$id]['state'] = 'foo'; // only one element was set to 'foo', not all of them. - assertType("non-empty-array", $array); + assertType("non-empty-array", $array); } /** @@ -39,7 +39,7 @@ function testUnionValue(array $array, int $id): void { function testUnionArray(array $array, int $id): void { $array[$id]['state'] = 'foo'; // only one element was set to 'foo', not all of them. - assertType("non-empty-array", $array); + assertType("non-empty-array", $array); } /**