Skip to content

Track array value type overwrites in by-reference foreach on constant arrays#5556

Closed
phpstan-bot wants to merge 4 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-olluoj0
Closed

Track array value type overwrites in by-reference foreach on constant arrays#5556
phpstan-bot wants to merge 4 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-olluoj0

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When iterating over a constant array (e.g. [1, 2, 3]) by reference and modifying the value variable, PHPStan did not track the type change back to the array. After the loop, the array retained its original type instead of reflecting the modifications.

This affected both foreach ($arr as &$v) (without key) and foreach ($arr as $k => &$v) (with key) on constant arrays.

Changes

  • src/Analyser/NodeScopeResolver.php:
    • Relaxed the isConstantArray()->no() guard at line 1355 to allow the value type rewrite when $stmt->byRef is true. The guard existed because non-by-ref constant array foreach is handled by loop unrolling, but by-ref foreach skips unrolling (tryProcessUnrolledConstantArrayForeach returns null for $stmt->byRef).
    • Added constant array handling in both TypeTraverser::map callbacks (for expr type and native type): uses $type->traverse(fn () => $newValueType) to replace all value types while preserving the ConstantArrayType structure. This check is placed before the instanceof ArrayType check because ConstantArrayType implements Type directly without extending ArrayType.
    • For the with-key branch: when the value was reassigned by reference ($stmt->byRef and OriginalForeachValueExpr absent), use the value variable's type as the dim fetch type. This is needed because the IntertwinedVariableByReferenceWithExpr mechanism (which normally propagates by-ref changes to the array dim fetch) is not set up for constant arrays.

Root cause

Three interacting guards prevented constant arrays from having their value types updated after by-ref foreach:

  1. tryProcessUnrolledConstantArrayForeach returns null for $stmt->byRef, so loop unrolling (which normally handles constant arrays) is skipped
  2. The isConstantArray()->no() condition blocked the post-loop value type rewrite for constant arrays
  3. ConstantArrayType doesn't extend ArrayType, so the instanceof ArrayType check in the TypeTraverser callback returned early without modifying it
  4. For the with-key case: IntertwinedVariableByReferenceWithExpr is only set up for non-constant arrays (guarded by isConstantArray()->no() in MutatingScope::enterForeach), so the dim fetch type didn't reflect by-ref modifications

Analogous cases probed and found to be already correct:

Test

Added tests/PHPStan/Analyser/nsrt/bug-1311-constant-array.php covering:

  • By-ref without key on constant array (the main reported case)
  • By-ref with key on constant array
  • By-ref with conditional modification
  • By-ref with break (should NOT rewrite)
  • By-ref with different transformation (strlen)
  • By-ref with string keys
  • By-ref without modification (no-op)

Fixes phpstan/phpstan#1311

@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-olluoj0 branch from 1a447f6 to c1924d6 Compare April 28, 2026 08:48
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge bug-1311-constant-array and bug-1311 in a single file

Merge bug-1940 and bug-1940-with-key in a single file

Ensure the code

$a = [
    [
        'one' => 'one',
        'two' => 'two',
    ],
    [
        'one' => 'one',
    ],
];

foreach ($a as &$testArray) {
	$testArray['two'] = 'two';
}

$key = 'three';

foreach ($a as $offset => $testArray) {
    $a[$offset][$key] = $key;

}
foreach ($a as $testArray) {
	$testArray['two'];
	$testArray['three'];
}

Does not produce the error Offset 'two' might not exist on array{one: 'one', two?: 'two', three: 'three'}. anymore.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this PR be simplified by improving part of MutatingScope ?

MutatingScope::enterForeach seems to already process byRef but is skipping constantArray

if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no())

Also MutatingScope::tryProcessUnrolledConstantArrayForeach currently has an early return for byRef...

- Merge bug-1311-constant-array.php into bug-1311.php (single file, single namespace)
- Merge bug-1940-with-key.php into bug-1940.php (single file, single namespace)
- Add test for constant array by-ref foreach with sub-element modification
  to verify no false positive about missing offset after mutations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

All three review items addressed and pushed:

  1. Merged bug-1311-constant-array.php into bug-1311.php - Combined both files into a single file under the Bug1311 namespace with one HelloWorld class containing all test methods (parameter-based and constant-array variants).

  2. Merged bug-1940-with-key.php into bug-1940.php - Combined both files into a single file under the Bug1940 namespace, adding the with-key and without-key sub-element modification tests alongside the existing tests.

  3. Added reviewer's constant array by-ref foreach test - Added constantArrayByRefSubElement() to bug-1311.php that verifies the exact scenario from the review: a constant array with heterogeneous elements gets sub-element modifications via by-ref foreach, followed by key-based assignment and final read - confirming no false positive about Offset 'two' might not exist.

Both make tests (11937 tests, 79452 assertions) and make phpstan (0 errors) pass cleanly.

Remove the isConstantArray()->no() guard from the with-key
IntertwinedVariableByReferenceWithExpr setup in enterForeach.
The ArrayDimFetch-based intertwined expression correctly targets
individual elements via the key variable, so it works for constant
arrays (unlike the SetExistingOffsetValueTypeExpr path which
updates all values at once and must remain guarded).

This lets the intertwined mechanism automatically propagate by-ref
type changes to the dim fetch for constant arrays, removing the
need for the elseif ($stmt->byRef) fallback branches in the
post-loop rewrite in NodeScopeResolver.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed successfully. Here's a summary of what was done:

Simplification via MutatingScope::enterForeach (addressing the current review):

  • Removed && $iterateeType->isConstantArray()->no() from the with-key IntertwinedVariableByReferenceWithExpr in enterForeach. The ArrayDimFetch-based intertwined expression targets individual elements via the key variable, so it works correctly for constant arrays.
  • This allowed removing the elseif ($stmt->byRef && $originalValueExpr !== null) fallback branches in NodeScopeResolver's post-loop rewrite — the intertwined mechanism now automatically propagates the correct dim fetch type.
  • The without-key SetExistingOffsetValueTypeExpr path retains its isConstantArray()->no() guard because it updates all array values simultaneously (using the general key type), which incorrectly broadens types for constant arrays.
  • Regarding tryProcessUnrolledConstantArrayForeach: supporting by-ref in unrolling would require per-element IntertwinedVariableByReferenceWithExpr setup and reference tracking, adding similar complexity without net simplification — so the early return is kept.

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