skip to content

Why does PHP's json_encode() sometimes emit a list as a JSON object, or an empty map as [], and how do you prevent it?

level: middleimportance: should knowfreq 48%

answer

  1. keys 0..n-1 in order, or not
  2. array_filter keeps the original keys
  3. array_values() reindexes
  4. an empty array is a list
  5. JSON_FORCE_OBJECT hits every array

basics

~20 s

json_encode() writes an array as a JSON list only if its keys are 0..n-1 in order, else as an object, and an empty array counts as a list. Reindex lists with array_values() and cast maps with (object).

solid answer

~40 s

PHP has one array type, so `json_encode()` decides per array: if `array_is_list()` would be true (keys `0, 1, 2, ...` in order), it writes `[...]`; otherwise it writes `{...}` with the keys as member names. Filtering a list with `array_filter()` or `unset()` keeps the old keys, so `[0 => 'a', 2 => 'c']` comes out as `{"0":"a","2":"c"}`, and the API's `items` field flips type. Fix it with `array_values()` before encoding. The mirror case is a map that can be empty: `[]` is a list, so it encodes as `[]` where the client expects `{}`; cast it with `(object) $map` or build a `stdClass`. `JSON_FORCE_OBJECT` is the blunt alternative: it turns every array in the value into an object, including real lists. `array_is_list()` exists since PHP 8.1.

code

php · 19 lines
php
<?php
declare(strict_types=1);

$members = [
    ['name' => 'Ana', 'active' => true],
    ['name' => 'Ben', 'active' => false],
    ['name' => 'Caz', 'active' => true],
];
$active = array_filter($members, fn(array $m): bool => $m['active']);

echo json_encode(['items' => $active]), "\n";
// {"items":{"0":{...},"2":{...}}}  - object, keys 0 and 2

echo json_encode(['items' => array_values($active)]), "\n";
// {"items":[{...},{...}]}          - list again

$settings = [];
echo json_encode(['settings' => $settings]), "\n";          // {"settings":[]}
echo json_encode(['settings' => (object) $settings]), "\n"; // {"settings":{}}

go deeper

for a junior

Recall that json_encode() writes [] for arrays with keys 0..n-1 and {} otherwise, and that array_values() reindexes.

for a middle

Explain which array operations keep or break list-ness, why an empty map encodes as [], and why JSON_FORCE_OBJECT is too broad.

for a senior

Prevent shape flips at the response boundary with DTOs, array_values() on list fields, object casts for maps, and tests on empty and filtered data.

for a principal

Decide how API response shapes are guaranteed across teams, for example schema-checked contract tests rather than per-endpoint encoding fixes.

## One PHP type, two JSON types JSON distinguishes **arrays** (ordered lists) from **objects** (key/value maps). PHP has a single `array` type, an ordered map, that plays both roles. `json_encode()` therefore has to guess for each array it meets, and the rule in the extension's source is: - if the array is a **list**, meaning its keys are exactly `0, 1, 2, ... n-1` in that order, encode `[...]`; - otherwise encode `{...}`, turning each key into a member name. PHP 8.1 exposes the same test as `array_is_list(array $array): bool`, which makes the rule easy to check in a test. ## The reserved bug: a list that turns into an object A typical API action builds a list, filters it, and returns it: ```php $items = array_filter($items, fn(array $i): bool => $i['active']); echo json_encode(['items' => $items]); ``` When every item is active, the keys are still `0..n-1` and the client gets `"items":[...]`. When item 1 is filtered out, the keys are `0, 2, 3`, and the client gets `"items":{"0":...,"2":...,"3":...}`. Typed clients then fail to parse the response, but only for some users and some data. Other ways to break list-ness: - `unset($list[$i])` inside a loop; - `asort()`, `arsort()` or `uasort()`, which keep keys while reordering; - `array_unique()`, which keeps the first key of each value; - building an array with explicit keys that start at 1. **Fix:** reindex with `array_values()` right before encoding, or use functions that reindex (`sort()`, `usort()`, `array_map()` on a list). ## The mirror bug: an empty map that turns into a list A field meant as a map, such as `settings` or `metadata`, is usually a string-keyed array. When it is empty, it is `[]`, and an empty array *is* a list, so `json_encode()` writes `[]`. The client expecting an object gets an array. **Fixes:** 1. cast at the boundary: `'settings' => (object) $settings`, which encodes an empty `stdClass` as `{}`; 2. model the field as an object in PHP (a `stdClass` or a class with public properties); 3. keep it an array and use `JSON_FORCE_OBJECT` only if **no** real list exists anywhere in the value. ## Why JSON_FORCE_OBJECT is rarely the fix `JSON_FORCE_OBJECT` is passed down to every nested array, so it turns every list into an object too: | Value | `json_encode($v)` | `json_encode($v, JSON_FORCE_OBJECT)` | |---|---|---| | `[]` | `[]` | `{}` | | `['a', 'b']` | `["a","b"]` | `{"0":"a","1":"b"}` | | `[0 => 'a', 2 => 'c']` | `{"0":"a","2":"c"}` | `{"0":"a","2":"c"}` | | `['x' => []]` | `{"x":[]}` | `{"x":{}}` | It is correct only for payloads that contain maps and nothing else. ## Decoding loses the distinction `json_decode($json, true)` turns both `{}` and `[]` into `[]`. A service that decodes a request in array mode and re-encodes part of it therefore turns empty objects into empty lists. When a payload must survive a round trip byte-for-byte in shape, decode in object mode, where `{}` becomes an empty `stdClass`. ## Guarding it in tests - assert the **shape** of API responses with an empty collection and with a filtered collection, not just the happy path; - assert `array_is_list($response['items'])` after decoding; - prefer response DTOs whose list fields are always passed through `array_values()`. ## Summary of the rules | PHP value | `json_encode()` output | |---|---| | `[]` | `[]` | | `['a', 'b']` | `["a","b"]` | | `[1 => 'a', 0 => 'b']` | `{"1":"a","0":"b"}`, keys out of order | | `['x' => 1]` | `{"x":1}` | | `new stdClass()` or `(object) []` | `{}` | The list test looks only at the keys and their order, never at the values, and it applies separately to every nested array.

  • Why is JSON_FORCE_OBJECT a risky fix for an empty settings map inside a larger response?
    The flag applies to every array in the value, so every real list in the same response, such as `items` or `tags`, also becomes an object with numeric keys. It only fits payloads that contain maps and no lists; for a mixed response, cast the one map with `(object)`.
  • Which PHP sort functions keep a list a list, and which break it?
    `sort()`, `rsort()` and `usort()` reindex the result, so it stays a list. `asort()`, `arsort()` and `uasort()` preserve keys while reordering, so the keys are no longer in 0..n-1 order and `json_encode()` writes an object.

saying these in an interview costs you the question

  • Believing array_filter() reindexes the array it returns
  • Using JSON_FORCE_OBJECT to fix one empty map in a mixed response
  • Assuming json_encode() writes an empty string-keyed array as {}
  • Thinking only string keys make json_encode() write an object
  • Round-tripping payloads through assoc decoding without losing {} vs []