PHP references: what & actually does and where it breaks code
The PHP manual says references aren't pointers, and it advises against returning by reference to gain performance. Both pieces of advice are correct and easy to forget. Here is what & does in the engine and where it breaks code.
Values, copy-on-write and references
By default PHP assigns and passes values. Arrays and strings are refcounted, so $b = $a doesn't copy anything. Both variables point at the same value and the refcount goes up. A copy (separation) happens only when one of them is written while the refcount is above one, which is what copy-on-write means.
A reference is different. $b = &$a wraps the value in a reference container that both names share. A write through either name changes the value both of them see, and there's no separation between $a and $b. If the value is also shared with another variable, though, the first write still copies it: after $x = [1]; $a = $x; $b = &$a; $b[] = 2;, $x is still [1].
$a = 'original';
$b = $a; // no copy, both names share one value
$b = 'modified'; // $b gets its own value
var_dump($a); // string(8) "original"
$a = 'original';
$b = &$a; // $a and $b are now one variable with two names
$b = 'modified';
var_dump($a); // string(8) "modified"
After the assignment nothing in the code marks $b as a reference. To know how a write to $b behaves, the reader has to find the line where & was used.
Bug 1: the reference left behind by foreach
$prices = [100, 200, 300, 400, 500];
foreach ($prices as &$price) {
$price = $price * 0.9;
}
// $prices is [90, 180, 270, 360, 450]
foreach ($prices as $price) {
echo $price, ' ';
}
// Output: 90 180 270 360 360
After the first loop $price is still a reference to $prices[4]. The second loop assigns each element to $price, which means it writes each element into $prices[4]. On the fourth iteration it writes 360 there, so the fifth iteration reads 360 instead of 450. It still happens in PHP 8. The PHP 7 change to foreach semantics didn't remove it.
The minimal fix is to break the reference right after the loop:
foreach ($prices as &$price) {
$price = $price * 0.9;
}
unset($price); // removes the name $price; $prices[4] keeps its value
Better still, don't use a reference at all:
foreach ($prices as $i => $price) {
$prices[$i] = $price * 0.9;
}
// or, when a new array is acceptable
$prices = array_map(fn (int|float $p): float => $p * 0.9, $prices);
Bug 2: a by-reference parameter changes the contract
Say a normalization function receives large arrays, and someone switches it to a by-reference parameter "to avoid copying".
// Before: pure function, the caller's array is untouched
function normaliseProduct(array $product): array
{
$product['title'] = trim(strtolower($product['title']));
$product['price'] = round($product['price'], 2);
return $product;
}
// After: mutates the argument and returns nothing
function normaliseProduct(array &$product): void
{
$product['title'] = trim(strtolower($product['title']));
$product['price'] = round($product['price'], 2);
}
Every existing call of the form $normalised = normaliseProduct($product) now stores null and modifies $product as a side effect. PHP itself doesn't report using the result of a void function. PHPStan (from level 0) or Psalm (at errorLevel 1 through 7, so every level except the loosest, 8) will, if they run in CI. And if a function is meant to mutate its argument, its name should say so (sortInPlace, applyDefaults).
The optimization also gains less than it seems. The by-value version copies the array once, when it writes the first key, and that copy is shallow: nested arrays stay shared until something writes to them. The by-reference version avoids that copy only when the caller's array isn't shared. If it's shared with another variable (refcount above one), the first write inside the function separates it anyway.
Bug 3: references survive inside arrays
A reference to an array element stays attached to that element. Copy the array and the reference goes along, so the element stays shared.
$a = [1, 2];
$ref = &$a[0];
$b = $a; // looks like a copy
$b[0] = 99;
var_dump($a[0]); // int(99)
The manual documents this. Of the three bugs it's the hardest to spot, because the & can be far away from the copy. It can be left behind, for example, by a by-reference foreach whose loop variable was never unset.
Objects are not passed by reference
Objects are passed by value, and the value is an object handle. Through the handle a function can change the object's state. Assigning a new object to the parameter changes only the local copy of the handle.
final class Counter
{
public int $count = 0;
}
function increment(Counter $counter): void
{
$counter->count++; // visible to the caller
$counter = new Counter(); // not visible to the caller
}
$c = new Counter();
increment($c);
var_dump($c->count); // int(1)
You only need & on an object parameter (Counter &$counter) when the function must replace the caller's object. Returning a new object is usually better.
When a reference is justified
- Output parameters of built-in functions:
preg_match($pattern, $subject, $matches),sort($array),array_push()and others take their argument by reference by design. - In-place modification of a large nested structure, for example a recursive walk over a tree of arrays that updates leaves. Passing by value separates each modified level on the way down and requires the caller to reassign the result. Measure with
memory_get_peak_usage()before deciding. For most request-sized data the difference is negligible. - Building a nested structure through a reference to the current node, a common pattern when turning a flat list with parent ids into a tree. Keep it inside one function and unset the reference before returning.
In application code that works on objects, the same effect is usually available without references: pass an object and mutate its state, or return a new value.