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
Every key, every counter and every JSON shape runs on the tested reducer, checked against PHP 8.4 by npm run verify:php.
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_valuesthrows 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_filterkeeps 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:
unsetnever lowers the counter. array_is_list()needs PHP 8.1. Before that the usual check wasarray_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_encodehas aJSON_FORCE_OBJECTflag 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_encodedecides 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.