From b5379a576ed3d090a19a4cda27b6b9c7bd355e90 Mon Sep 17 00:00:00 2001
From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com>
Date: Sun, 22 Feb 2026 17:14:15 +0000
Subject: [PATCH 01/10] Fix array_key_exists false positive on list types with
optional trailing keys
When `ConstantArrayType::unsetOffset()` removed an optional trailing key
from a list type, it unconditionally set `isList` to `No`. This caused
`TypeCombinator::remove()` to produce `NeverType` when re-intersecting
with `AccessoryArrayListType`, since the list accessor rejects non-list
arrays. The `NeverType` falsy branch then collapsed scope merging after
conditionals like ternaries, propagating the narrowed (truthy) type
beyond the conditional and triggering false "always true" reports on
subsequent `array_key_exists()` calls.
The fix preserves the original `isList` value when unsetting an optional
key whose removal leaves the remaining keys as consecutive integers
starting from 0.
Fixes https://github.com/phpstan/phpstan/issues/14177
---
src/Type/Constant/ConstantArrayType.php | 16 ++++++++++-
tests/PHPStan/Analyser/nsrt/bug-14177.php | 21 +++++++++++++++
...mpossibleCheckTypeFunctionCallRuleTest.php | 6 +++++
.../Rules/Comparison/data/bug-14177.php | 27 +++++++++++++++++++
4 files changed, 69 insertions(+), 1 deletion(-)
create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14177.php
create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14177.php
diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php
index 6cff7ef3af5..2cc4776bf7d 100644
--- a/src/Type/Constant/ConstantArrayType.php
+++ b/src/Type/Constant/ConstantArrayType.php
@@ -741,7 +741,21 @@ public function unsetOffset(Type $offsetType): Type
$k++;
}
- return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, TrinaryLogic::createNo());
+ $newIsList = TrinaryLogic::createNo();
+ if (!$this->isList->no() && in_array($i, $this->optionalKeys, true)) {
+ $preserveIsList = true;
+ foreach ($newKeyTypes as $k2 => $newKeyType2) {
+ if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) {
+ $preserveIsList = false;
+ break;
+ }
+ }
+ if ($preserveIsList) {
+ $newIsList = $this->isList;
+ }
+ }
+
+ return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList);
}
return $this;
diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php
new file mode 100644
index 00000000000..45df4cf02c4
--- /dev/null
+++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php
@@ -0,0 +1,21 @@
+analyse([__DIR__ . '/data/pr-4375.php'], []);
}
+ public function testBug14177(): void
+ {
+ $this->treatPhpDocTypesAsCertain = true;
+ $this->analyse([__DIR__ . '/data/bug-14177.php'], []);
+ }
+
}
diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14177.php b/tests/PHPStan/Rules/Comparison/data/bug-14177.php
new file mode 100644
index 00000000000..9c45152e4ef
--- /dev/null
+++ b/tests/PHPStan/Rules/Comparison/data/bug-14177.php
@@ -0,0 +1,27 @@
+',
+ $id,
+ array_key_exists(3, $matches) ? sprintf(' class="%s"', $matches[3]) : '',
+ );
+
+ return array_key_exists(2, $matches) && $matches[2] !== ''
+ ? sprintf('%s', $matches[2], $replacement)
+ : $replacement;
+ },
+ $html,
+ );
+ }
+}
From 86e7f3f7254d4804233d568f19d76c271a7c7e07 Mon Sep 17 00:00:00 2001
From: Vincent Langlet
Date: Sun, 22 Feb 2026 20:34:33 +0100
Subject: [PATCH 02/10] WIP
---
src/Type/Constant/ConstantArrayType.php | 11 +--
tests/PHPStan/Analyser/nsrt/bug-14177.php | 98 +++++++++++++++++++++++
2 files changed, 99 insertions(+), 10 deletions(-)
diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php
index 2cc4776bf7d..cd37f1ac131 100644
--- a/src/Type/Constant/ConstantArrayType.php
+++ b/src/Type/Constant/ConstantArrayType.php
@@ -743,16 +743,7 @@ public function unsetOffset(Type $offsetType): Type
$newIsList = TrinaryLogic::createNo();
if (!$this->isList->no() && in_array($i, $this->optionalKeys, true)) {
- $preserveIsList = true;
- foreach ($newKeyTypes as $k2 => $newKeyType2) {
- if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) {
- $preserveIsList = false;
- break;
- }
- }
- if ($preserveIsList) {
- $newIsList = $this->isList;
- }
+ $newIsList = TrinaryLogic::createMaybe();
}
return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList);
diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php
index 45df4cf02c4..a5230531650 100644
--- a/tests/PHPStan/Analyser/nsrt/bug-14177.php
+++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php
@@ -18,4 +18,102 @@ public function testList(array $b): void
}
assertType('list{0: string, 1: string, 2?: string, 3?: string}', $b);
}
+
+ /**
+ * @param list{0: string, 1: string, 2?: string, 3?: string} $b
+ */
+ public function testUnset0(array $b): void
+ {
+ assertType('true', array_is_list($b));
+ unset($b[0]);
+ assertType('false', array_is_list($b));
+ $b[] = 'foo';
+ assertType('false', array_is_list($b));
+ }
+
+ /**
+ * @param list{0: string, 1: string, 2?: string, 3?: string} $b
+ */
+ public function testUnset1(array $b): void
+ {
+ assertType('true', array_is_list($b));
+ unset($b[1]);
+ assertType('bool', array_is_list($b));
+ $b[] = 'foo';
+ assertType('false', array_is_list($b));
+ }
+
+ /**
+ * @param list{0: string, 1: string, 2?: string, 3?: string} $b
+ */
+ public function testUnset2(array $b): void
+ {
+ assertType('true', array_is_list($b));
+ unset($b[2]);
+ assertType('bool', array_is_list($b));
+ $b[] = 'foo';
+ assertType('false', array_is_list($b));
+ }
+
+ /**
+ * @param list{0: string, 1: string, 2?: string, 3?: string} $b
+ */
+ public function testUnset3(array $b): void
+ {
+ assertType('true', array_is_list($b));
+ unset($b[3]);
+ assertType('true', array_is_list($b));
+ $b[] = 'foo';
+ assertType('false', array_is_list($b));
+ }
+
+ public function placeholderToEditor(string $html): void
+ {
+ $result = preg_replace_callback(
+ '~\[image\\sid="(\\d+)"(?:\\shref="([^"]*)")?(?:\\sclass="([^"]*)")?\]~',
+ function (array $matches): string {
+ $id = (int) $matches[1];
+
+ assertType('list{0: non-falsy-string, 1: numeric-string, 2?: string, 3?: string}', $matches);
+
+ $replacement = sprintf(
+ '
',
+ $id,
+ array_key_exists(3, $matches) ? sprintf(' class="%s"', $matches[3]) : '',
+ );
+
+ assertType('list{0: non-falsy-string, 1: numeric-string, 2?: string, 3?: string}', $matches);
+
+ return array_key_exists(2, $matches) && $matches[2] !== ''
+ ? sprintf('%s', $matches[2], $replacement)
+ : $replacement;
+ },
+ $html,
+ );
+ }
+
+ public function placeholderToEditor2(string $html): void
+ {
+ $result = preg_replace_callback(
+ '~\[image\\sid="(\\d+)?"(?:\\shref="([^"]*)")?(?:\\sclass="([^"]*)")?\]~',
+ function (array $matches): string {
+ $id = (int) $matches[0];
+
+ assertType('list{0: non-falsy-string, 1?: \'\'|numeric-string, 2?: string, 3?: string}', $matches);
+
+ $replacement = sprintf(
+ '
',
+ $id,
+ array_key_exists(2, $matches) ? sprintf(' class="%s"', $matches[2]) : '',
+ );
+
+ assertType('list{0: non-falsy-string, 1?: \'\'|numeric-string, 2?: string, 3?: string}', $matches);
+
+ return array_key_exists(1, $matches) && $matches[1] !== ''
+ ? sprintf('%s', $matches[1], $replacement)
+ : $replacement;
+ },
+ $html,
+ );
+ }
}
From d7d28b93777a992e7a9a8c1f53c0579970436509 Mon Sep 17 00:00:00 2001
From: Vincent Langlet
Date: Sun, 22 Feb 2026 22:34:58 +0100
Subject: [PATCH 03/10] Rework
---
src/Type/Constant/ConstantArrayType.php | 27 +++-
tests/PHPStan/Analyser/nsrt/bug-14177.php | 147 +++++++++++++++-------
2 files changed, 124 insertions(+), 50 deletions(-)
diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php
index cd37f1ac131..318b5ada418 100644
--- a/src/Type/Constant/ConstantArrayType.php
+++ b/src/Type/Constant/ConstantArrayType.php
@@ -742,8 +742,31 @@ public function unsetOffset(Type $offsetType): Type
}
$newIsList = TrinaryLogic::createNo();
- if (!$this->isList->no() && in_array($i, $this->optionalKeys, true)) {
- $newIsList = TrinaryLogic::createMaybe();
+ if (!$this->isList->no()) {
+ $preserveIsList = true;
+ $isListOnlyIfKeysAreOptional = false;
+ foreach ($newKeyTypes as $k2 => $newKeyType2) {
+ if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) {
+ // We found a non-optional key that implies that the array is never a list.
+ if (!in_array($k2, $newOptionalKeys, true)) {
+ $preserveIsList = false;
+ break;
+ }
+
+ // The array can still be a list if all the following keys are also optional.
+ $isListOnlyIfKeysAreOptional = true;
+ continue;
+ }
+
+ if ($isListOnlyIfKeysAreOptional && !in_array($k2, $newOptionalKeys, true)) {
+ $preserveIsList = false;
+ break;
+ }
+ }
+
+ if ($preserveIsList) {
+ $newIsList = $this->isList;
+ }
}
return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList);
diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php
index a5230531650..8e2a20db945 100644
--- a/tests/PHPStan/Analyser/nsrt/bug-14177.php
+++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php
@@ -19,54 +19,6 @@ public function testList(array $b): void
assertType('list{0: string, 1: string, 2?: string, 3?: string}', $b);
}
- /**
- * @param list{0: string, 1: string, 2?: string, 3?: string} $b
- */
- public function testUnset0(array $b): void
- {
- assertType('true', array_is_list($b));
- unset($b[0]);
- assertType('false', array_is_list($b));
- $b[] = 'foo';
- assertType('false', array_is_list($b));
- }
-
- /**
- * @param list{0: string, 1: string, 2?: string, 3?: string} $b
- */
- public function testUnset1(array $b): void
- {
- assertType('true', array_is_list($b));
- unset($b[1]);
- assertType('bool', array_is_list($b));
- $b[] = 'foo';
- assertType('false', array_is_list($b));
- }
-
- /**
- * @param list{0: string, 1: string, 2?: string, 3?: string} $b
- */
- public function testUnset2(array $b): void
- {
- assertType('true', array_is_list($b));
- unset($b[2]);
- assertType('bool', array_is_list($b));
- $b[] = 'foo';
- assertType('false', array_is_list($b));
- }
-
- /**
- * @param list{0: string, 1: string, 2?: string, 3?: string} $b
- */
- public function testUnset3(array $b): void
- {
- assertType('true', array_is_list($b));
- unset($b[3]);
- assertType('true', array_is_list($b));
- $b[] = 'foo';
- assertType('false', array_is_list($b));
- }
-
public function placeholderToEditor(string $html): void
{
$result = preg_replace_callback(
@@ -117,3 +69,102 @@ function (array $matches): string {
);
}
}
+
+class HelloWorld2
+{
+ /**
+ * @param list{0: string, 1: string, 2?: string, 3?: string} $b
+ */
+ public function testUnset0OnList(array $b): void
+ {
+ assertType('true', array_is_list($b));
+ unset($b[0]);
+ assertType('false', array_is_list($b));
+ $b[] = 'foo';
+ assertType('false', array_is_list($b));
+ }
+
+ /**
+ * @param list{0: string, 1: string, 2?: string, 3?: string} $b
+ */
+ public function testUnset1OnList(array $b): void
+ {
+ assertType('true', array_is_list($b));
+ unset($b[1]);
+ assertType('bool', array_is_list($b));
+ $b[] = 'foo';
+ assertType('bool', array_is_list($b)); // Could be false
+ }
+
+ /**
+ * @param list{0: string, 1: string, 2?: string, 3?: string} $b
+ */
+ public function testUnset2OnList(array $b): void
+ {
+ assertType('true', array_is_list($b));
+ unset($b[2]);
+ assertType('bool', array_is_list($b));
+ $b[] = 'foo';
+ assertType('bool', array_is_list($b)); // Could be false
+ }
+
+ /**
+ * @param list{0: string, 1: string, 2?: string, 3?: string} $b
+ */
+ public function testUnset3OnList(array $b): void
+ {
+ assertType('true', array_is_list($b));
+ unset($b[3]);
+ assertType('bool', array_is_list($b)); // Could be true
+ $b[] = 'foo';
+ assertType('bool', array_is_list($b)); // Could be false
+ }
+
+ /**
+ * @param array{0: string, 1?: string, 2: string, 3?: string} $b
+ */
+ public function testUnset0OnArray(array $b): void
+ {
+ assertType('bool', array_is_list($b));
+ unset($b[0]);
+ assertType('false', array_is_list($b));
+ $b[] = 'foo';
+ assertType('false', array_is_list($b));
+ }
+
+ /**
+ * @param array{0: string, 1?: string, 2: string, 3?: string} $b
+ */
+ public function testUnset1OnArray(array $b): void
+ {
+ assertType('bool', array_is_list($b));
+ unset($b[1]);
+ assertType('false', array_is_list($b));
+ $b[] = 'foo';
+ assertType('false', array_is_list($b));
+ }
+
+ /**
+ * @param array{0: string, 1?: string, 2: string, 3?: string} $b
+ */
+ public function testUnset2OnArray(array $b): void
+ {
+ assertType('bool', array_is_list($b));
+ unset($b[2]);
+ assertType('bool', array_is_list($b));
+ $b[] = 'foo';
+ assertType('bool', array_is_list($b)); // Could be false
+ }
+
+ /**
+ * @param array{0: string, 1?: string, 2: string, 3?: string} $b
+ */
+ public function testUnset3OnArray(array $b): void
+ {
+ assertType('bool', array_is_list($b));
+ unset($b[3]);
+ assertType('bool', array_is_list($b));
+ $b[] = 'foo';
+ assertType('bool', array_is_list($b)); // Could be false
+ }
+}
From 89f5a54620f575581018c57b669101a2e91593b1 Mon Sep 17 00:00:00 2001
From: Vincent Langlet
Date: Sun, 22 Feb 2026 23:02:21 +0100
Subject: [PATCH 04/10] Simplify
---
src/Type/Constant/ConstantArrayType.php | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php
index 318b5ada418..68d35361701 100644
--- a/src/Type/Constant/ConstantArrayType.php
+++ b/src/Type/Constant/ConstantArrayType.php
@@ -742,7 +742,10 @@ public function unsetOffset(Type $offsetType): Type
}
$newIsList = TrinaryLogic::createNo();
- if (!$this->isList->no()) {
+ // We're unsetting something that might not be on the array,
+ // so it might still be a list (with PHPStan definition)
+ // because the nextAutoIndexes will not change.
+ if (!$this->isList->no() && in_array($i, $this->optionalKeys, true)) {
$preserveIsList = true;
$isListOnlyIfKeysAreOptional = false;
foreach ($newKeyTypes as $k2 => $newKeyType2) {
@@ -765,7 +768,7 @@ public function unsetOffset(Type $offsetType): Type
}
if ($preserveIsList) {
- $newIsList = $this->isList;
+ $newIsList = TrinaryLogic::createMaybe();
}
}
From 405eecf2cedaf1807bc962202e30a50f41a299e6 Mon Sep 17 00:00:00 2001
From: Vincent Langlet
Date: Sun, 22 Feb 2026 23:06:07 +0100
Subject: [PATCH 05/10] Fix
---
tests/PHPStan/Analyser/nsrt/array-is-list-unset.php | 2 +-
tests/PHPStan/Analyser/nsrt/bug-14177.php | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php b/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php
index b14b8c97df8..f464ff183e3 100644
--- a/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php
+++ b/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php
@@ -19,7 +19,7 @@ function () {
assertType('true', array_is_list($a));
unset($a[2]);
assertType('array{1, 2}', $a);
- assertType('false', array_is_list($a));
+ assertType('false', array_is_list($a)); // Could be true
$a[] = 4;
assertType('array{0: 1, 1: 2, 3: 4}', $a);
assertType('false', array_is_list($a));
diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php
index 8e2a20db945..c0eed10781b 100644
--- a/tests/PHPStan/Analyser/nsrt/bug-14177.php
+++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php
@@ -1,6 +1,6 @@
Date: Sun, 22 Feb 2026 23:19:42 +0100
Subject: [PATCH 06/10] Handle non constant unset
---
src/Type/Constant/ConstantArrayType.php | 87 +++++++++++++++----------
1 file changed, 53 insertions(+), 34 deletions(-)
diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php
index 68d35361701..719d774750f 100644
--- a/src/Type/Constant/ConstantArrayType.php
+++ b/src/Type/Constant/ConstantArrayType.php
@@ -741,36 +741,12 @@ public function unsetOffset(Type $offsetType): Type
$k++;
}
- $newIsList = TrinaryLogic::createNo();
- // We're unsetting something that might not be on the array,
- // so it might still be a list (with PHPStan definition)
- // because the nextAutoIndexes will not change.
- if (!$this->isList->no() && in_array($i, $this->optionalKeys, true)) {
- $preserveIsList = true;
- $isListOnlyIfKeysAreOptional = false;
- foreach ($newKeyTypes as $k2 => $newKeyType2) {
- if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) {
- // We found a non-optional key that implies that the array is never a list.
- if (!in_array($k2, $newOptionalKeys, true)) {
- $preserveIsList = false;
- break;
- }
-
- // The array can still be a list if all the following keys are also optional.
- $isListOnlyIfKeysAreOptional = true;
- continue;
- }
-
- if ($isListOnlyIfKeysAreOptional && !in_array($k2, $newOptionalKeys, true)) {
- $preserveIsList = false;
- break;
- }
- }
-
- if ($preserveIsList) {
- $newIsList = TrinaryLogic::createMaybe();
- }
- }
+ $newIsList = $this->isListAfterUnset(
+ $newKeyTypes,
+ $newOptionalKeys,
+ $this->isList,
+ in_array($i, $this->optionalKeys, true),
+ );
return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList);
}
@@ -801,21 +777,64 @@ public function unsetOffset(Type $offsetType): Type
}
}
- return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, TrinaryLogic::createNo());
+ $newIsList = $this->isListAfterUnset(
+ $this->keyTypes,
+ $optionalKeys,
+ $this->isList,
+ count($optionalKeys) === count($this->optionalKeys),
+ );
+
+ return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList);
}
$optionalKeys = $this->optionalKeys;
- $isList = $this->isList;
foreach ($this->keyTypes as $i => $keyType) {
if (!$offsetType->isSuperTypeOf($keyType)->yes()) {
continue;
}
$optionalKeys[] = $i;
- $isList = TrinaryLogic::createNo();
}
$optionalKeys = array_values(array_unique($optionalKeys));
- return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $isList);
+ $newIsList = $this->isListAfterUnset(
+ $this->keyTypes,
+ $optionalKeys,
+ $this->isList,
+ count($optionalKeys) === count($this->optionalKeys),
+ );
+
+ return new self($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList);
+ }
+
+ /**
+ * If we're unsetting something that might not be on the array, it might still be a list (with PHPStan definition)
+ * because the nextAutoIndexes will not change.
+ */
+ private function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic
+ {
+ if (!$unsetOptionalKey || $arrayIsList->no()) {
+ return TrinaryLogic::createNo();
+ }
+
+ $isListOnlyIfKeysAreOptional = false;
+ foreach ($newKeyTypes as $k2 => $newKeyType2) {
+ if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) {
+ // We found a non-optional key that implies that the array is never a list.
+ if (!in_array($k2, $newOptionalKeys, true)) {
+ return TrinaryLogic::createNo();
+ }
+
+ // The array can still be a list if all the following keys are also optional.
+ $isListOnlyIfKeysAreOptional = true;
+ continue;
+ }
+
+ if ($isListOnlyIfKeysAreOptional && !in_array($k2, $newOptionalKeys, true)) {
+ return TrinaryLogic::createNo();
+ }
+ }
+
+ return TrinaryLogic::createMaybe();
}
public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type
From 77b49bfb4290550fd67cfa28e57a37e2312f3805 Mon Sep 17 00:00:00 2001
From: Vincent Langlet
Date: Sun, 22 Feb 2026 23:26:26 +0100
Subject: [PATCH 07/10] More
---
.../Analyser/nsrt/array-is-list-unset.php | 2 +-
tests/PHPStan/Analyser/nsrt/bug-14177.php | 35 ++++++++++++++++++-
2 files changed, 35 insertions(+), 2 deletions(-)
diff --git a/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php b/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php
index f464ff183e3..b14b8c97df8 100644
--- a/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php
+++ b/tests/PHPStan/Analyser/nsrt/array-is-list-unset.php
@@ -19,7 +19,7 @@ function () {
assertType('true', array_is_list($a));
unset($a[2]);
assertType('array{1, 2}', $a);
- assertType('false', array_is_list($a)); // Could be true
+ assertType('false', array_is_list($a));
$a[] = 4;
assertType('array{0: 1, 1: 2, 3: 4}', $a);
assertType('false', array_is_list($a));
diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php
index c0eed10781b..c20901af8f7 100644
--- a/tests/PHPStan/Analyser/nsrt/bug-14177.php
+++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php
@@ -151,7 +151,7 @@ public function testUnset2OnArray(array $b): void
{
assertType('bool', array_is_list($b));
unset($b[2]);
- assertType('false', array_is_list($b)); // Could be true
+ assertType('false', array_is_list($b));
$b[] = 'foo';
assertType('false', array_is_list($b));
}
@@ -167,4 +167,37 @@ public function testUnset3OnArray(array $b): void
$b[] = 'foo';
assertType('bool', array_is_list($b)); // Could be false
}
+
+ /**
+ * @param list{0: string, 1: string, 2?: string, 3?: string} $a
+ * @param list{0: string, 1?: string, 2?: string, 3?: string} $b
+ * @param list{0: string, 1?: string, 2?: string, 3: string} $c
+ * @param 1|2 $int
+ */
+ public function testUnsetNonConstant(array $a, array $b, array $c, int $int): void
+ {
+ assertType('true', array_is_list($a));
+ assertType('true', array_is_list($b));
+ assertType('true', array_is_list($c));
+ unset($a[$int]);
+ unset($b[$int]);
+ unset($c[$int]);
+ assertType('false', array_is_list($a));
+ assertType('bool', array_is_list($b));
+ assertType('bool', array_is_list($c));
+ }
+
+ /**
+ * @param list{0?: string, 1?: string, 2?: string, 3?: string} $a
+ * @param list{0: string, 1?: string, 2?: string, 3?: string} $b
+ */
+ public function testUnsetInt(array $a, array $b, array $c, int $int): void
+ {
+ assertType('true', array_is_list($a));
+ assertType('true', array_is_list($b));
+ unset($a[$int]);
+ unset($b[$int]);
+ assertType('bool', array_is_list($a));
+ assertType('false', array_is_list($b));
+ }
}
From 1d76e964aeb40089e8ef6ed1f42d4cee77ee745f Mon Sep 17 00:00:00 2001
From: Vincent Langlet
Date: Sun, 22 Feb 2026 23:40:34 +0100
Subject: [PATCH 08/10] Improve
---
src/Type/Constant/ConstantArrayType.php | 24 ++++++++++++++++++------
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php
index 719d774750f..80679e3365f 100644
--- a/src/Type/Constant/ConstantArrayType.php
+++ b/src/Type/Constant/ConstantArrayType.php
@@ -741,7 +741,7 @@ public function unsetOffset(Type $offsetType): Type
$k++;
}
- $newIsList = $this->isListAfterUnset(
+ $newIsList = self::isListAfterUnset(
$newKeyTypes,
$newOptionalKeys,
$this->isList,
@@ -758,6 +758,7 @@ public function unsetOffset(Type $offsetType): Type
if (count($constantScalars) > 0) {
$optionalKeys = $this->optionalKeys;
+ $arrayHasChanged = false;
foreach ($constantScalars as $constantScalar) {
$constantScalar = $constantScalar->toArrayKey();
if (!$constantScalar instanceof ConstantIntegerType && !$constantScalar instanceof ConstantStringType) {
@@ -769,6 +770,7 @@ public function unsetOffset(Type $offsetType): Type
continue;
}
+ $arrayHasChanged = true;
if (in_array($i, $optionalKeys, true)) {
continue 2;
}
@@ -777,7 +779,11 @@ public function unsetOffset(Type $offsetType): Type
}
}
- $newIsList = $this->isListAfterUnset(
+ if (!$arrayHasChanged) {
+ return $this;
+ }
+
+ $newIsList = self::isListAfterUnset(
$this->keyTypes,
$optionalKeys,
$this->isList,
@@ -788,15 +794,21 @@ public function unsetOffset(Type $offsetType): Type
}
$optionalKeys = $this->optionalKeys;
+ $arrayHasChanged = false;
foreach ($this->keyTypes as $i => $keyType) {
if (!$offsetType->isSuperTypeOf($keyType)->yes()) {
continue;
}
+ $arrayHasChanged = true;
$optionalKeys[] = $i;
}
$optionalKeys = array_values(array_unique($optionalKeys));
- $newIsList = $this->isListAfterUnset(
+ if (!$arrayHasChanged) {
+ return $this;
+ }
+
+ $newIsList = self::isListAfterUnset(
$this->keyTypes,
$optionalKeys,
$this->isList,
@@ -807,10 +819,10 @@ public function unsetOffset(Type $offsetType): Type
}
/**
- * If we're unsetting something that might not be on the array, it might still be a list (with PHPStan definition)
- * because the nextAutoIndexes will not change.
+ * When we're unsetting something not on the array, it will be untouched,
+ * So the nextAutoIndexes won't change, and the array might still be a list even with PHPStan definition.
*/
- private function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic
+ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic
{
if (!$unsetOptionalKey || $arrayIsList->no()) {
return TrinaryLogic::createNo();
From faf726db33f687542fb2b9f61e952158e19305bd Mon Sep 17 00:00:00 2001
From: Vincent Langlet
Date: Sun, 22 Feb 2026 23:47:05 +0100
Subject: [PATCH 09/10] Fix
---
src/Type/Constant/ConstantArrayType.php | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php
index 80679e3365f..e2db1f96456 100644
--- a/src/Type/Constant/ConstantArrayType.php
+++ b/src/Type/Constant/ConstantArrayType.php
@@ -821,6 +821,9 @@ public function unsetOffset(Type $offsetType): Type
/**
* When we're unsetting something not on the array, it will be untouched,
* So the nextAutoIndexes won't change, and the array might still be a list even with PHPStan definition.
+ *
+ * @param list $newKeyTypes
+ * @param int[] $newOptionalKeys
*/
private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic
{
From 2a30bf181587dee42b4e08b4f3e90190c8410ad8 Mon Sep 17 00:00:00 2001
From: Vincent Langlet
Date: Mon, 23 Feb 2026 09:36:01 +0100
Subject: [PATCH 10/10] Feedback
---
src/Type/Constant/ConstantArrayType.php | 2 +-
tests/PHPStan/Analyser/nsrt/bug-14177.php | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php
index e2db1f96456..cc1a545e252 100644
--- a/src/Type/Constant/ConstantArrayType.php
+++ b/src/Type/Constant/ConstantArrayType.php
@@ -823,7 +823,7 @@ public function unsetOffset(Type $offsetType): Type
* So the nextAutoIndexes won't change, and the array might still be a list even with PHPStan definition.
*
* @param list $newKeyTypes
- * @param int[] $newOptionalKeys
+ * @param int[] $newOptionalKeys
*/
private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic
{
diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php
index c20901af8f7..16107251705 100644
--- a/tests/PHPStan/Analyser/nsrt/bug-14177.php
+++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php
@@ -91,7 +91,7 @@ public function testUnset1OnList(array $b): void
{
assertType('true', array_is_list($b));
unset($b[1]);
- assertType('false', array_is_list($b)); // Could be true
+ assertType('false', array_is_list($b));
$b[] = 'foo';
assertType('false', array_is_list($b));
}
@@ -105,7 +105,7 @@ public function testUnset2OnList(array $b): void
unset($b[2]);
assertType('bool', array_is_list($b));
$b[] = 'foo';
- assertType('bool', array_is_list($b)); // Could be false
+ assertType('bool', array_is_list($b));
}
/**
@@ -115,9 +115,9 @@ public function testUnset3OnList(array $b): void
{
assertType('true', array_is_list($b));
unset($b[3]);
- assertType('bool', array_is_list($b)); // Could be true
+ assertType('bool', array_is_list($b));
$b[] = 'foo';
- assertType('bool', array_is_list($b)); // Could be false
+ assertType('bool', array_is_list($b));
}
/**
@@ -165,7 +165,7 @@ public function testUnset3OnArray(array $b): void
unset($b[3]);
assertType('bool', array_is_list($b));
$b[] = 'foo';
- assertType('bool', array_is_list($b)); // Could be false
+ assertType('bool', array_is_list($b));
}
/**