In PHP, why is comparing json_decode()'s result with null an unreliable error check, and what does JSON_THROW_ON_ERROR change?
answer
- the text null is valid JSON
- json_last_error() and its message
- JsonException since 7.3
- throwing leaves the global error alone
- json_validate() since 8.3
basics
~10 sjson_decode() returns null both for invalid input and for the valid JSON text null, so a null check cannot tell them apart. JSON_THROW_ON_ERROR makes json_decode() and json_encode() throw JsonException instead of setting json_last_error().
solid answer
~40 sWithout flags, `json_decode()` reports failure by returning `null` and recording a code you read with `json_last_error()` (`JSON_ERROR_SYNTAX`, `JSON_ERROR_DEPTH`, `JSON_ERROR_UTF8` and so on) or `json_last_error_msg()`. Since `null` is also the result of decoding the valid text `null`, `=== null` is ambiguous, and forgetting the follow-up call lets bad input flow on silently. Passing `JSON_THROW_ON_ERROR` (PHP 7.3+) makes `json_decode()` and `json_encode()` throw a `JsonException`, whose `getCode()` is the `JSON_ERROR_*` value. In throw mode the global error state is not touched, so `json_last_error()` afterwards still reports an earlier call. `json_encode()` has the same split: `false` plus the global code, or an exception. To validate without decoding, PHP 8.3 added `json_validate()`.
code
php · 19 lines<?php
declare(strict_types=1);
function parsePayload(string $body): array
{
try {
$data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
// $e->getCode() === JSON_ERROR_SYNTAX, JSON_ERROR_DEPTH, ...
throw new InvalidArgumentException('Malformed JSON: ' . $e->getMessage(), 0, $e);
}
if (!is_array($data)) {
throw new InvalidArgumentException('Expected a JSON object or array');
}
return $data;
}
var_dump(json_decode('null')); // NULL, and it is valid
var_dump(json_last_error() === JSON_ERROR_NONE); // bool(true)go deeper
Recall that invalid JSON gives null from json_decode() and that json_last_error_msg() explains why.
Explain why null is ambiguous, how JSON_THROW_ON_ERROR and JsonException replace the global error state, and when json_validate() helps.
Enforce one error style across a codebase, catch JsonException at input boundaries, and spot stale json_last_error() reads in mixed code.
Decide how malformed input is surfaced across services: rejection codes, logging of the raw payload size, and whether validation happens before storage.
## Two error-reporting styles PHP's JSON functions have two ways to report failure, and the choice is made per call with a flag. **Legacy style (no flag).** `json_decode()` returns `null` and `json_encode()` returns `false`. The reason is stored in a per-request global you read with: - `json_last_error(): int`, one of the `JSON_ERROR_*` constants; - `json_last_error_msg(): string`, such as `"Syntax error"` or `"Maximum stack depth exceeded"`. **Exception style (`JSON_THROW_ON_ERROR`, since PHP 7.3).** Both functions throw a `JsonException` (a subclass of `Exception`) whose message is the same text and whose code is the `JSON_ERROR_*` value. ## Why a null check is not enough `json_decode()` returns `null` for three different situations: 1. the input is invalid (`{"a":`, an empty string, a stray comma); 2. the input nests deeper than `$depth` (default 512); 3. the input is the valid JSON text `null`. A check like `if ($data === null)` cannot separate 3 from 1 and 2. And the common bug is worse: code that never checks at all passes `null` onward, and the failure surfaces far away as a `TypeError` or a missing field. ## The throw-mode detail that bites In the extension's source, `json_decode()` resets and writes the global error code **only when `JSON_THROW_ON_ERROR` is absent**. In throw mode it leaves the global untouched. So: | Sequence | `json_last_error()` afterwards | |---|---| | a failed call without the flag, then a successful call with the flag | still the old error code | | a successful call without the flag | `JSON_ERROR_NONE` | | any call with the flag that throws | unchanged from before | Mixing the two styles and reading `json_last_error()` after a throwing call reports a stale result. Pick one style per codebase; with the flag, the `catch` block is the only error channel. `json_encode()` behaves the same way, with one exception: `JSON_PARTIAL_OUTPUT_ON_ERROR` takes precedence over `JSON_THROW_ON_ERROR`, replacing unencodable values with `null` and recording the code in the global instead of throwing. ## Common error codes | Constant | Typical cause | |---|---| | `JSON_ERROR_SYNTAX` | malformed text, including an empty string | | `JSON_ERROR_DEPTH` | nesting deeper than `$depth` | | `JSON_ERROR_UTF8` | malformed UTF-8 in the input | | `JSON_ERROR_CTRL_CHAR` | control character in the text | | `JSON_ERROR_INF_OR_NAN` | encoding `INF` or `NAN` | | `JSON_ERROR_NON_BACKED_ENUM` | encoding a pure enum case | For `json_decode()` and `json_validate()`, a `$depth` of 0 or less is a programming error, not bad data: it throws a `ValueError` regardless of flags. ## json_validate() in PHP 8.3+ `json_validate(string $json, int $depth = 512, int $flags = 0): bool` checks syntax without building the PHP value, so it uses less memory than a throwaway decode. It sets `json_last_error()`, so the message is available after a `false`. Its only accepted flag is `JSON_INVALID_UTF8_IGNORE`; anything else throws a `ValueError`. Use it when you only need a yes/no answer, for example before storing the raw text. If you will decode anyway, validating first parses the text twice; decode once with `JSON_THROW_ON_ERROR` instead. ## A house rule that works - Always pass `JSON_THROW_ON_ERROR` to `json_decode()` and `json_encode()`. - Catch `JsonException` at the boundary that received the input and turn it into a 400-style response or a logged rejection. - Never read `json_last_error()` in code that uses the flag. ## Encoding failures follow the same split `json_encode()` returns `false` in legacy mode, and `echo json_encode($x)` then prints an empty string, a classic source of empty API responses. With `JSON_THROW_ON_ERROR` the same failure is a `JsonException`, so it reaches the error handler instead of the client as a blank body. ## Putting it together 1. Decode request bodies with `json_decode($body, true, 512, JSON_THROW_ON_ERROR)`. 2. Check the decoded type (`is_array()`) before indexing into it, since valid JSON may be a scalar. 3. Map `JsonException` to a client error at the boundary; do not let it surface as a 500. 4. Encode responses with `JSON_THROW_ON_ERROR` so encoding bugs are loud.
- After json_decode($x, flags: JSON_THROW_ON_ERROR) throws, what does json_last_error() return?Whatever the last non-throwing JSON call left there. In throw mode `json_decode()` neither resets nor sets the global code, so it can still say `JSON_ERROR_NONE` or report an unrelated earlier failure. The exception's `getCode()` is the reliable value.
- When is json_validate() the right call, and when is it wasteful?It fits when you only need to know whether text is valid JSON, such as before storing a raw document, because it parses without building PHP values. If you are going to decode the text anyway, calling it first parses twice; decode once with `JSON_THROW_ON_ERROR` and catch `JsonException`.
- What does json_decode('') do in PHP 8.5?An empty string is a syntax error. Without the flag it returns `null` and sets `JSON_ERROR_SYNTAX`; with `JSON_THROW_ON_ERROR` it throws a `JsonException` with message "Syntax error".
saying these in an interview costs you the question
- Treating a null return from json_decode() as proof of invalid input
- Reading json_last_error() after a call that used JSON_THROW_ON_ERROR
- Expecting json_decode() to throw on bad input without any flag
- Calling json_validate() and then json_decode() on the same text
- Expecting JSON_THROW_ON_ERROR to leave null-plus-error-code reporting in place