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

Simulator · PHP memory model

Every box, refcount and reference runs on the tested reducer, checked against PHP 8.4.

$b = $a shares one box (refcount 2). No copy yet.

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”. $b shares $a’s box, so PHP copies the array first, then appends. $b becomes [1, 2, 3, 4] in a new box and $a keeps [1, 2, 3]. That split is copy-on-write.
  • Reset, set from $a and to $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: $a changes too, and no box is copied.
  • Copy $a to $b, then Reference $a to $c. Nothing is copied. One box, refcount two: $b holds the array, and $a and $c hold it through their reference. Append to $a and the split finally happens, with the whole bracket moving to the new box and $b left 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. clone makes 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 &$param makes 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_ref flag, 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

Spotted a problem, or have a way to make this clearer? Suggest an improvement.