References & memory

Why your JSON array turned into an object

The mistake

You have a list. You filter one item out of it. It is still a list, one item shorter, and everything downstream carries on as before.

Except PHP does not have a list. It has one array type, and that type is a hashmap that remembers the order things were put into it. What you call a list is an appearance: the keys happen to be integers, starting at zero, with no gaps. Take one element out of the middle and the appearance goes, while the values, the order and count() all look exactly as you expected. Nothing errors. Then json_encode looks at those keys, sees a gap, and writes a JSON object, because a JSON array cannot have one. Your API response changed shape and no line of your code says so.

The machine

Simulator · array keys

Every key, every counter and every JSON shape runs on the tested reducer, checked against PHP 8.4 by npm run verify:php.

unset($ids[1]); The keys are now 0, 2. json_encode returns a JSON object. Removed one element. Every other key kept the value it had, and the next free key is still 3: unset never hands a key back. The keys are no longer a run from zero. json_encode has to write an object, because JSON arrays cannot have gaps.

Drive it

The panel opens with one element already removed, so the bug is on screen before you touch anything.

  • Read the JSON, then press the featured button. array_values throws the keys away and numbers the values again, and the shape comes back. That is the whole fix.
  • Reset, then press array_filter(). Nothing was removed by hand this time, and the JSON is an object anyway. array_filter keeps the key of everything that survives. This is how the bug actually reaches production.
  • Press $ids[] = after removing something, and watch which key it takes. Not the next free one. Then press + [ ] and watch it do nothing at all.

The mechanism

Every PHP array is a hashtable with an insertion order. Keys can be integers or strings, they are stored in the order they were first written, and nothing keeps them in numerical order or closes a gap when one appears. array_is_list(), added in PHP 8.1, is the function that answers the only question that matters here: are the keys exactly 0, 1, 2, ... in that order? json_encode asks itself the same question, and writes [...] when the answer is yes and {...} when it is no.

So the operations split into two groups, and it is worth knowing which is which.

These keep the keys. unset removes one element and leaves the others where they were. array_filter keeps the key of every value that passes the test. Both leave gaps, and a gap is enough.

These build a new array. array_values renumbers from zero, which is the repair you want, and says so at the call site. array_merge renumbers integer keys too, so it also repairs the run, as a side effect of doing something else.

And then there is +. The union operator is not array_merge with nicer spelling. It keeps the left hand side wherever the two arrays share a key, so $ids + [40] adds nothing at all when $ids already has a key 0. In the panel you can watch it run and change nothing. When key 0 is free, it does add the value, and it puts it at the end, after the higher keys, because insertion order is what the array records.

There is one more number in play, and you cannot see it from the outside. Every array keeps a counter for the next integer key $ids[] will use. Removing an element never lowers it, so a freed key is never handed out again: unset the last element of a three element list and the next append still takes key 3. A function that builds a new array starts a fresh counter from the keys that ended up in it, which is why array_filter and array_values reset it. The panel shows the counter next to the shape.

Keys also get coerced on the way in, before any of this applies. $ids["1"] and $ids[true] and $ids[1.9] all land on the integer key 1, so all three overwrite each other. $ids[null] lands on the empty string. Only a string that is a canonical decimal integer is converted, which is why "1" becomes 1 and "01" stays a string.

In your code

The version that ships the bug, and the version that does not:

$ids = [10, 20, 30];

$active = array_filter($ids, fn ($id) => $id > 15);
return response()->json($active);        // {"1":20,"2":30}

$active = array_values(array_filter($ids, fn ($id) => $id > 15));
return response()->json($active);        // [20,30]

Wrapping the filter in array_values is the habit worth building. If you would rather assert than remember, array_is_list($active) is one call and reads well in a test:

$this->assertTrue(array_is_list($active));

A Laravel Collection sidesteps most of this, because values() is on it for the same reason and ->filter()->values() is a common pair. It is still the same array underneath, so a toArray() on a filtered collection has the same shape problem the moment it reaches json_encode.

The fine print

  • The panel keeps at least one element in the array on purpose. On an emptied array the next free key stops being something this page can state: it depends on the internal representation, and PHP’s own answers vary with what you do next. Rather than print a number it cannot stand behind, the simulator stays out of that state. The rule you can rely on is the one above: unset never lowers the counter.
  • array_is_list() needs PHP 8.1. Before that the usual check was array_keys($a) === range(0, count($a) - 1), which is correct and much slower.
  • A negative integer key is allowed, and what an append does after one changed in PHP 8.3. If you are relying on that, check it on your own version.
  • json_encode has a JSON_FORCE_OBJECT flag for going the other way deliberately. There is no matching flag for forcing an array, because there cannot be one: the keys would have nowhere to go.
  • This page is about keys. When PHP copies an array, when it shares one, and what a reference does to all of it, is the subject of when PHP copies a value, and when it shares it.

Further reading

  • PHP: Arrays is the manual page that states the key coercion rules in full, including the ones this page leaves out.
  • PHP: array_is_list is short, and it defines “list” exactly as json_encode decides it.
  • PHP RFC: array_is_list is worth reading for the motivation: it exists because this bug was common enough to need a first-class answer.

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