Reading JSON in PHP With json decode and Throw on Error
json_decode in PHP 8.5 returns null on bad input unless you throw. Arrays or objects, big integers, depth 512, flags and the list versus object trap.

PHP has had json_decode and json_encode since 5.2, and they are the first thing a PHP developer reaches for. They are simple and they have habits that cost people an afternoon: a failure that looks the same as a null, a number that quietly changes type, an array that turns into an object. Using them well is mostly knowing where those are.
I ran the sample order and a list of edge cases through PHP 8.5, and the results below are what it printed.
Arrays or objects
$a = json_decode($json, true); // associative arrays
$o = json_decode($json); // stdClass objectsWith true as the second argument the sample came back as nested arrays: $a['id'] was 9007199254740993 (an int on 64-bit PHP), $a['customer']['email'] was NULL, and $a['items'][1]['price'] was 12, an int because JSON 12 has no fraction. Without it, objects: $o->customer->name was Ana. Pick one and stay with it. Arrays are easier to loop and to pass around, objects fail more clearly on a typo in code (reading a missing property gives a warning, and ?? is the safe form: $o->nope ?? 'unset').
A key that is not a valid PHP identifier still works as a property in curly braces: $x->{'a-b'}. An empty key {"":1} decoded to an object with a property named "".
The failure that looks like null
The first trap is the return value. A syntax error returns null:
var_dump(json_decode('{"a":1,}')); // NULL
json_last_error(); // 4
json_last_error_msg(); // "Syntax error"And the valid text null also returns NULL, with json_last_error_msg() saying No error. So if ($data === null) cannot tell failure from a document that said null. An empty string is an error too (Syntax error), which matters when a request body is empty.
The remedy is the flag added in PHP 7.3:
try {
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
// $e->getMessage() === "Syntax error", $e->getCode() === 4
}It threw JsonException with the same message and code. Use it everywhere. The alternative is to call json_last_error() after every call, and the one that forgets is the bug. With the flag set, json_last_error() is not touched, which also fixes the old global-state problem where an unrelated earlier call left an error code behind.
Numbers
json_decode('12345678901234567890'); // 1.2345678901234567E+19 (float)
json_decode('12345678901234567890', false, 512, JSON_BIGINT_AS_STRING); // '12345678901234567890'An integer that does not fit in 64 bits becomes a float and loses its low digits. The flag JSON_BIGINT_AS_STRING keeps it as a string. The 16 digit order id from the sample fits in an int, so it was exact. Ids that your own database produced are fine; ids from systems that use 128-bit values or snowflake ids written as numbers beyond 2^63 are not. Keep a bigint as a string in the contract (JSON security mistakes in apps and pipelines shows how other parsers treated the same number).
The reverse direction has its own rule. json_encode(1.0) wrote 1, not 1.0, and json_encode(['a' => 1.0], JSON_PRESERVE_ZERO_FRACTION) wrote 1.0. The precision of floats follows serialize_precision, whose default of -1 gives the shortest string that round-trips: 0.1 + 0.2 encoded as 0.30000000000000004. Money should be integers or strings, not floats.
Depth and duplicates
The third argument of json_decode is the depth, 512 by default:
json_decode(str_repeat('[', 511) . str_repeat(']', 511)); // ok
json_decode(str_repeat('[', 512) . str_repeat(']', 512)); // NULL, "Maximum stack depth exceeded"511 nested arrays parsed and 512 failed. The limit is a guard, and you can lower it for untrusted input: a depth of 32 is plenty for most request bodies (limiting depth and size of incoming JSON). The same parameter raised to 1000 let a 600 level document through.
Duplicate keys keep the last value: {"role":"user","role":"admin"} decoded to ['role' => 'admin']. There is no switch to reject duplicates, so a body that decides authorization needs a check of its own or a stricter parser. Note also that {"0":"a","1":"b"} and ["a","b"] decoded to arrays that compared equal with ===, because PHP arrays do not distinguish a list from a map with integer keys.
Encoding: lists, objects and flags
That last point shows up when writing. json_encode decides between a list and an object by looking at the keys:
json_encode([0 => 'a', 1 => 'b']); // ["a","b"]
json_encode([1 => 'a', 2 => 'b']); // {"1":"a","2":"b"}
json_encode(array_filter([1, 0, 2])); // {"0":1,"2":2}An array with keys 0..n-1 in order is a list. Anything else, including a list after array_filter or unset that left a gap, is an object. This is the cause of the API response that was an array yesterday and an object today. Re-index with array_values() before encoding anything that must be a list. An empty PHP array encodes as []; to get {} encode new stdClass or use JSON_FORCE_OBJECT.
The flags that matter:
json_encode("é/", JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); // "é/"
json_encode("é/"); // "é\/"By default non-ASCII is escaped to \uXXXX and the slash is escaped as \/. Both are valid JSON and both are noise in logs. The escaped slash has a use: it keeps </script> from ending a script block when you embed JSON in HTML, and removing it needs JSON_HEX_TAG to compensate, which turns < and > into < and >.
Invalid UTF-8 makes json_encode return false with Malformed UTF-8 characters, possibly incorrectly encoded. With JSON_INVALID_UTF8_SUBSTITUTE it wrote "bad�" instead. NAN and INF return false with Inf and NaN cannot be JSON encoded. Pass JSON_THROW_ON_ERROR here too, or false will be concatenated into a response as an empty string.
Controlling what gets written
Objects encode their public properties only. An anonymous class with public, protected and private members encoded as {"a":1}, so private data does not leak through json_encode. To control the output, implement JsonSerializable:
class P implements JsonSerializable {
public function __construct(private string $n, private string $secret) {}
public function jsonSerialize(): array { return ['name' => $this->n]; }
}
json_encode(new P('ana', 's3cr3t')); // {"name":"ana"}That also places the public face of a model in one method. Public properties are the part to watch: anything you add as public later appears in every response that encodes the object.
PHP has no built-in typed binding from JSON to classes. Frameworks and libraries (Symfony Serializer, Laravel resources, cuyz/valinor) add it. Without one, decode to an array, validate it (validating request JSON before it reaches the database) and build your objects from it. The JSON to PHP converter makes array literals and classes from a sample. The reference pages are the manual for json_decode and json_encode.
When json_decode alone is not enough
Untrusted input. Add a size limit before the call, a low depth, and a schema check after. The function by itself bounds only depth, with a generous default.
Large documents. json_decode builds the whole structure in memory. For multi-hundred-megabyte files, read line by line (JSON Lines) or use a streaming parser such as halaxa/json-machine, which I did not test here.
Exact numbers. For money and large ids, use JSON_BIGINT_AS_STRING and decimal strings, and do arithmetic with bcmath or integers of minor units.
Typed models. If the data feeds domain objects, add a mapper rather than reading arrays everywhere. The array is a transport and not a model.


