skip to content

Why does a Laravel collection returned after filter() or sortBy() sometimes serialize to a JSON object instead of an array?

level: middleimportance: should knowfreq 45%

answer

  1. keys survive the operation
  2. json_encode and gapped integer keys
  3. passes tests when only the tail is dropped
  4. values() resets to 0..n-1
  5. chunk() preserves keys by default

basics

~20 s

filter(), reject(), sortBy(), unique() and partition() keep each item's original key. Once the keys are no longer 0, 1, 2 in order, json_encode emits an object; calling values() last re-indexes the collection so it serializes as an array.

solid answer

~30 s

Collection methods that select or reorder items keep the original keys: `filter()`, `reject()`, `unique()`, `sortBy()`/`sortByDesc()`, both halves of `partition()` and, by default, `chunk()`. Serialisation goes through `json_encode`, and PHP encodes an array as a JSON array only when its keys are exactly `0..n-1` in order; anything else becomes an object such as `{"1":…,"2":…}`. So the bug appears only when the removed item was not at the end or the order changed, which is why it slips through tests. The fix is to end the chain with `->values()`, which resets the keys. `pluck()` without a key argument already re-indexes.

code

php · 15 lines
php
<?php

$answers = collect([
    ['department' => 'IT',      'score' => 2],
    ['department' => 'Support', 'score' => 5],
    ['department' => 'Support', 'score' => 4],
]);

$support = $answers->filter(fn ($a) => $a['department'] === 'Support');

echo $support->toJson();
// {"1":{"department":"Support","score":5},"2":{...}}

echo $support->values()->toJson();
// [{"department":"Support","score":5},{...}]

go deeper

for a junior

Remember that filter(), sortBy() and unique() keep the original keys and that values() resets them so the JSON is an array.

for a middle

Explain json_encode's list rule, which methods preserve keys and which re-index, and why the bug depends on which items were removed.

for a senior

Treat the JSON shape as part of the contract: add values() at the boundary, and add tests whose fixtures remove a leading item so the regression cannot hide.

for a principal

Decide where response shaping lives — a transformation layer that always normalises lists — so individual controllers do not each have to remember values().

## The symptom An endpoint returns a Laravel collection, a JavaScript client calls `.map()` on the payload, and it crashes because the response body is `{"1": {...}, "3": {...}}` instead of `[{...}, {...}]`. Nothing in the PHP code looks wrong: the collection was filtered or sorted, then returned. The cause is **key preservation** meeting **PHP's JSON encoder**. ## Why keys survive A Laravel collection wraps a PHP array, and PHP arrays always have keys. Methods that *select* or *reorder* items keep each item's key so that associative data (for example answers keyed by respondent ID) keeps meaning: | Method | Keeps original keys? | Source detail | |---|---|---| | `filter()` / `reject()` | yes | built on `Arr::where` / `array_filter` | | `unique()` | yes | `array_unique` or a `reject()` pass | | `sortBy()` / `sortByDesc()` / `sort()` | yes | `asort` / `arsort` | | `partition()` | yes, in both halves | copies `$key => $item` | | `chunk($size)` | yes by default | `$preserveKeys = true` | | `map()` | yes | `Arr::map` recombines keys | | `pluck('field')` | **no** — re-indexed | appends with `$results[]` | | `values()` | **no** — re-indexed | `array_values` | | `groupBy()` inner groups | **no** unless `$preserveKeys = true` | groups are re-indexed | The Laravel docs call this out for `sort`, `sortBy` and `unique`, each with an example ending in `->values()`. ## Why JSON turns it into an object Collections implement `JsonSerializable`; `toJson()` is `json_encode($this->jsonSerialize())`, and returning a collection from a controller produces a JSON response built the same way. PHP's encoder follows one rule: - if the array's keys are exactly `0, 1, 2, …, n-1` **in that order**, it is a **list** and becomes a JSON array; - otherwise it becomes a JSON **object** whose property names are the keys. After `filter()` removes the first item, the keys are `1, 2, …` — not a list. After `sortBy()`, the keys are all present but out of order, e.g. `2, 0, 1` — also not a list. ## Why it only happens "sometimes" The bug depends on the data: 1. If `filter()` removes only items at the **end**, the surviving keys are still `0..n-1` and you get an array. 2. If nothing is removed and the order is unchanged, you get an array. 3. If any gap or reordering appears, you get an object. A test fixture where the filtered-out row happens to be last passes; production data with the unwanted row first breaks the client. This is why interviewers like the question: it separates people who have debugged it from people who have only read the method list. ## The fix and where to put it Call **`values()`** as the last step before the collection leaves your code: ```php <?php return $answers ->filter(fn ($a) => $a['department'] === 'Support') ->sortByDesc('score') ->values(); // keys reset to 0..n-1, always a JSON array ``` Guidelines: - Put `values()` at the **end** of the chain; a later `filter()` or `sortBy()` would create gaps again. - Do **not** add it when the keys carry meaning — a collection built with `keyBy('respondent_id')` is *meant* to be an object. - `all()` and `toArray()` do **not** re-index; they return the keys as they are. - For nested results, re-index each level: `->groupBy('department')` gives groups that are already re-indexed, but a filtered group inside a `map()` needs its own `values()`. ## Debugging a report of "sometimes an object" 1. Log `$collection->keys()` just before the response; gaps or a non-ascending order confirm the cause. 2. Walk the chain backwards to the last key-preserving method (`filter`, `reject`, `unique`, `sortBy`, `partition`, `chunk`). 3. Add `->values()` after it, at the boundary where the data leaves PHP. 4. Add a test fixture whose excluded item is **first**, so the regression cannot hide behind a lucky ordering. ## Related key behaviour worth knowing - `chunk(2)` on a five-item list gives chunks keyed `0,1`, `2,3` and `4`; the second and third chunks serialize as objects unless you pass `false` as the second argument or call `values()` on each. - `merge()` renumbers integer keys but lets string keys overwrite each other; `concat()` always appends. - `keyBy()` and `pluck('value', 'key')` deliberately build associative collections, so an object in the JSON is the intended shape there.

  • Does chunk() on a Laravel collection keep keys, and does that affect JSON?
    Yes. `chunk($size, $preserveKeys = true)` passes the flag straight to `array_chunk`, so every chunk after the first starts at a non-zero key and serializes as a JSON object. Pass `false` as the second argument, or call `values()` on each chunk, when the chunks must be JSON arrays.
  • When should you not call values() on a Laravel collection before returning it?
    When the keys are data. A collection built with `keyBy('respondent_id')` or `pluck('score', 'respondent_id')` is meant to serialize as an object mapping IDs to values, and `values()` would throw those IDs away.

saying these in an interview costs you the question

  • Blames the JSON response class and hand-encodes the array instead of re-indexing.
  • Thinks toArray() or all() resets the keys to 0..n-1.
  • Believes filter() always renumbers the remaining items.
  • Says sorting cannot change the JSON shape because no items are removed.
  • Calls values() before a later filter() and assumes the result stays a list.