References & memory
When PHP copies a value, and when it shares it
The mistake
Two beliefs, and both are half wrong. The first: that $b = $a copies the whole
array right away, so it is slow and the two are now completely separate. The
second, once someone learns the first is wrong: that $b = $a just points at the
same thing, so changing one changes the other.
PHP sits between the two. Assignment is by value, so $a and $b are logically
independent. But PHP does not copy until it has to. Both names share one value
until one of them is written to, and only then is the value duplicated. That is
copy-on-write. A reference, $b = &$a, is the separate thing people confuse
it with: two names bound to one slot, where a write through either is seen by
both.
The machine
Every box, refcount and reference runs on the tested reducer, checked against PHP 8.4.
Drive it
Each value lives in a box. The box shows its refcount, and names bound together
through one reference sit inside a dashed bracket.
- Press “Append to $b”.
$bshares$a’s box, so PHP copies the array first, then appends.$bbecomes[1, 2, 3, 4]in a new box and$akeeps[1, 2, 3]. That split is copy-on-write. - Reset, set
from$aandto$b, press Reference. Both names move into the bracket, and the refcount stays at one, because together they are one holder of the array. Now press Append:$achanges too, and no box is copied. - Copy
$ato$b, then Reference$ato$c. Nothing is copied. One box, refcount two:$bholds the array, and$aand$chold it through their reference. Append to$aand the split finally happens, with the whole bracket moving to the new box and$bleft on the original.
The mechanism
The array lives in one place, drawn here as a box, and it carries a refcount.
What that counts is holders, not names. Every name that holds the array directly is
one holder. A reference is also one holder, however many names are bound to it,
because those names all point at a single reference container and that container is
what holds the array.
$b = $a does not copy. It makes $b a second holder and adds one to the
refcount, which is cheap no matter how large the array is. $b = &$a does not copy
either. It binds the two names to one slot, and that slot is a single holder.
One rule then covers the write:
- refcount above one, so the value is shared. Before the write lands, PHP duplicates the array so the change cannot leak. The writing holder takes the new copy and the others keep the old one. This is the deferred copy finally happening. When the writer is a reference, the whole set moves across together, because it is one holder and it is still one slot.
- refcount of one, so nothing else holds it. The write lands in place. If that single holder is a reference of several names, every one of them sees the change, which is the behaviour people mean when they say a reference is shared.
This is why taking a reference to a value that is already shared costs nothing at
the time. When $a and $b share an array and you write $c = &$a, PHP binds
$a and $c to one slot and leaves the array exactly where it is, now with two
holders. The separation waits for the next write, like any other.
Assignment into a reference is the case that surprises people. $b = $c, where
$b is a reference, does not rebind $b. It writes $c’s value through the slot,
so every name in the set changes with it.
This is the root of the foreach reference trap. After
foreach ($items as &$row), the variable $row is still a reference to the last
element of the array. The next foreach ($items as $row) then assigns into that
reference on every pass, quietly overwriting the last element. Ending the first
loop with unset($row) breaks the reference and the bug with it.
In your code
$a = [1, 2, 3];
$b = $a; // no copy: $a and $b share one array (refcount 2)
$b[] = 4; // the write splits it: $a is [1,2,3], $b is [1,2,3,4]
$c = [1, 2, 3];
$d = &$c; // $d and $c are now the same variable
$d[] = 4; // so $c is [1,2,3,4] as well
// the foreach trap
foreach ($rows as &$row) {
$row = trim($row);
}
unset($row); // without this, the next loop over $rows corrupts the last row
Objects do not play by these rules. Assigning an object copies its handle, not the object, so both names point at the same instance and there is no copy-on-write:
$cart = new Cart();
$also = $cart; // same cart, not a copy
$also->add($item); // $cart sees it too
$copy = clone $cart; // a real, separate cart
The fine print
- Copy-on-write applies to arrays and strings. Small scalars like integers and floats are stored directly and are not shared this way, so the dance never shows for them.
- Objects are shared by handle, as above.
clonemakes a shallow copy, and a class can define__clone()to deep-copy what it holds. - The box here tracks names only. PHP’s real refcount also counts internal uses, so the exact number can differ from what you would count by eye.
- Passing an argument follows the same model: by value it shares until written, and
a
&$parammakes the parameter a reference to the caller’s variable. - References are not pointers. You cannot point a reference at another reference, and there is no address arithmetic. A reference is a container holding a value, not an address you can do sums on.
- If you learned an
is_refflag, that was PHP 5. Back then the flag sat on the value itself, and because a flagged value could not be shared with a plain variable, PHP had to copy eagerly the moment a reference met a shared array. PHP 7 moved references into their own container, so those copies became deferred like every other. The values you end up with are the same either way. The memory you spend, and when you spend it, are not.
Further reading
- PHP: references explained is the manual’s own account of what a reference does, and does not do.
- PHP: reference counting basics
covers refcounts and how copy-on-write falls out of them. Note that its
is_reflanguage describes PHP 5. - Nikita Popov: internal value representation in PHP 7 is the deep version, straight from the engine, on how a zval is actually laid out.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.