Debug PHP foreach mutations and lingering references
Reason about PHP 8 array iteration, copy-on-write, in-loop mutation, and the reference that survives after foreach.
A PHP loop that prints the expected values can still leave the array corrupted afterward. The classic symptom is a final element that mysteriously becomes a duplicate during a second loop. The cause is usually not an array pointer or a random engine bug: a variable used for by-reference iteration remains an alias to the last element until the reference is explicitly broken.
Start with the smallest reproducer
<?php
$items = ['alpha', 'beta', 'gamma'];
foreach ($items as &$item) {
$item = strtoupper($item);
}
foreach ($items as $item) {
echo $item, PHP_EOL;
}
var_export($items);
Without cleanup, the second loop repeatedly assigns its by-value result into $item, which is still a reference to $items[2]. The last element is overwritten as the loop progresses. The safe minimum is:
foreach ($items as &$item) {
$item = strtoupper($item);
}
unset($item); // break the alias to the last element
unset($item) removes the variable binding; it does not delete the array element. Put it immediately after the reference loop so a later return, exception, or refactor cannot extend the hazardous lifetime.
Use the PHP 8 model, not PHP 5 folklore
Current PHP documents foreach as iterating arrays and Traversable objects. For arrays, foreach does not modify the internal array pointer used by current(), key(), next(), and reset(). Explanations that say a modern loop advances that shared pointer describe old PHP 5 implementation details and should not guide PHP 8 debugging.
By-value and by-reference loops are different contracts:
foreach ($rows as $row) {
$row['ready'] = true; // changes the local value, not the array element
}
foreach ($rows as &$row) {
$row['ready'] = true; // changes each element
}
unset($row);
PHP arrays use copy-on-write internally, so “it always copies the whole array” is also a misleading mental shortcut. The engine can share storage until a write requires separation. Write code from observable language semantics, then profile memory if scale makes allocation behavior important.
Prefer key-based mutation when it is clearer
foreach ($rows as $key => $row) {
$rows[$key]['ready'] = validate($row);
}
This avoids a lingering loop reference and makes the write target visible. It is especially useful when code review, static analysis, or later refactoring matters more than saving a few characters. For a pure transformation, array_map can make the “new value for every old value” contract clearer, but it is not automatically faster or more memory-efficient.
Mutation during iteration needs an explicit policy
Do not mix traversal and structural edits casually. Decide whether the loop should process the original membership or a changing collection. For removals, a two-phase approach is easier to verify:
$remove = [];
foreach ($jobs as $id => $job) {
if (isExpired($job)) {
$remove[] = $id;
}
}
foreach ($remove as $id) {
unset($jobs[$id]);
}
If a live work queue is intended, express it as a queue with a termination condition rather than relying on whether appended array elements happen to become visible. Iterator implementations can have their own mutation rules, and generators are single-pass stateful computations; do not assume array behavior transfers to every Traversable.
A disciplined diagnosis
- Capture the PHP runtime version and reproduce with the same extensions and error reporting as production.
- Search for
foreachvariables preceded by&, including loops earlier in the same function scope. - Inspect writes to the loop variable after the loop. Variable scope does not end at a closing brace in PHP.
- Log keys and types, not sensitive values.
debug_zval_dumpcan help in an isolated test, but its reference-count details are diagnostic internals, not an application contract. - Add an assertion for the entire resulting array, including key order and the final element.
Avoid “fixing” the incident by renaming the second loop variable alone. That can hide the symptom, but the stale reference remains available to future code. Break it deliberately.
Production failure modes
- Long-lived workers: a corrupted in-memory job list can affect multiple messages. Fail the current unit of work, reconstruct state from its source of truth, and recycle the worker if invariants are uncertain.
- Shared nested values: references inside nested arrays can survive copies and serialize surprising state. Eliminate reference construction at boundaries and test deep structures.
- Database batches: a duplicated final identifier can update the wrong row. Use transactions, affected-row checks, idempotency keys, and a unique operation record.
- Version migration: a PHP 5 reproducer may not reproduce on PHP 8 because iteration internals changed. Test supported runtimes; do not preserve obsolete behavior by accident.
Verification, rollout, and recovery
Add regression cases for one element, multiple elements, an empty array, associative keys, an exception inside the loop, and a second loop reusing the variable name. Run them with warnings elevated in CI. Compare a shadow result before rolling a transformation into a high-value batch.
If bad data was persisted, stop the writer first. Identify affected records from audit history and the exact deployment window, restore or recompute values from an authoritative source, and verify referential constraints before reopening traffic. Rolling back PHP code prevents more corruption but does not repair rows already overwritten.
Attribution and current verification
This guide was prompted by DaveRandom’s Stack Overflow question and NikiC’s accepted answer, used under CC BY-SA. The answer’s PHP 7 model superseded its PHP 5 discussion; current guidance here was independently checked against the official PHP manual for foreach, array pointers, and reference cleanup, PHP references, and Traversable.
Primary source: Review the official reference ↗