Skip to content

Fix #13809: list<mixed> becomes array<int<0, max>, mixed> without array keys being modified#4933

Merged
staabm merged 1 commit into2.1.xfrom
create-pull-request/patch-987wd8h
Feb 15, 2026
Merged

Fix #13809: list<mixed> becomes array<int<0, max>, mixed> without array keys being modified#4933
staabm merged 1 commit into2.1.xfrom
create-pull-request/patch-987wd8h

Conversation

@phpstan-bot
Copy link
Collaborator

@phpstan-bot phpstan-bot commented Feb 15, 2026

Summary

When iterating a list<mixed> with foreach ($list as &$value) (without a key variable) and modifying $value, PHPStan incorrectly degraded the type to array<int<0, max>, mixed>, losing the list property. The same loop with foreach ($list as $key => &$value) correctly preserved the list type.

Changes

  • Changed SetOffsetValueTypeExpr to SetExistingOffsetValueTypeExpr in MutatingScope::enterForeach() at src/Analyser/MutatingScope.php:3026 for the no-key by-reference case
  • Added regression test in tests/PHPStan/Analyser/nsrt/bug-13809.php
  • Updated CLAUDE.md with documentation about the SetExistingOffsetValueTypeExpr vs SetOffsetValueTypeExpr distinction in foreach context

Root cause

In MutatingScope::enterForeach(), when creating the IntertwinedVariableByReferenceWithExpr for foreach ($list as &$value) (no key variable), the code used SetOffsetValueTypeExpr to represent the array modification. This expression calls setOffsetValueType() on the array type, which in AccessoryArrayListType returns ErrorType for non-null/non-zero offset types (like int<0, max>). The ErrorType result destroys the list accessory type during intersection resolution.

The fix uses SetExistingOffsetValueTypeExpr instead, which calls setExistingOffsetValueType(). This is semantically correct because a foreach loop only modifies values at existing offsets, never adding new ones. AccessoryArrayListType::setExistingOffsetValueType() returns $this, correctly preserving the list type.

Test

The regression test covers:

  • foreach ($list as &$value) without key — verifies list type is preserved (was the reported bug)
  • foreach ($list as $key => &$value) with key — verifies list type is preserved (already worked)
  • foreach ($list as &$value) with typed list (list<string>) — verifies typed list is preserved
  • foreach ($list as &$value) with arithmetic modification — verifies typed list is preserved

Fixes phpstan/phpstan#13809

Closes phpstan/phpstan#1311
Closes phpstan/phpstan#13851
Closes phpstan/phpstan#14083
Closes phpstan/phpstan#14084

- Changed SetOffsetValueTypeExpr to SetExistingOffsetValueTypeExpr in
  MutatingScope::enterForeach() for the no-key by-reference case
- In foreach ($list as &$value), values are modified at existing offsets,
  not new ones, so setExistingOffsetValueType must be used to preserve
  AccessoryArrayListType
- New regression test in tests/PHPStan/Analyser/nsrt/bug-13809.php

Closes phpstan/phpstan#13809
@staabm staabm merged commit 4de1cdc into 2.1.x Feb 15, 2026
635 of 641 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants