01 · THE IDEAREAD → TRY → LEARN
The problem with a quiet null
By default, json_decode() reports failure by returning null. That would be fine if null could never be a successful result, but the JSON text null is perfectly valid and decodes to null too. When a function returns the same value for "the input said null" and "the input was broken", callers cannot tell the two apart without extra work.
The first example shows both cases. After a failed decode you can ask json_last_error_msg() what went wrong, but that is a second step that is easy to forget.
var_dump(json_decode('null'));
var_dump(json_decode('{bad'));
echo json_last_error_msg(), PHP_EOL;NULL NULL Syntax error
Both var_dump() calls print NULL. Only the error message reveals that the second input was malformed.
Throwing instead of returning null
The signature is json_decode(string $json, ?bool $associative = null, int $depth = 512, int $flags = 0): mixed. The flags argument accepts JSON_THROW_ON_ERROR. With that flag, a decoding failure throws a JsonException instead of returning null. The exception message describes the problem and getCode() returns the matching JSON error code.
try {
json_decode('{"a":1,}', true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
echo get_class($e), ': ', $e->getMessage(), ' (', $e->getCode(), ')', PHP_EOL;
}JsonException: Syntax error (4)
A trailing comma is not allowed in JSON, so the call throws. Because the failure is an exception, a successful null result is now unmistakable: if no exception was thrown, null came from the input.
Note that depth and flags are positional, so to reach flags you must also pass associative and depth. Using true and 512 keeps the defaults you would normally want for arrays. Exceeding the nesting depth is also reported as a JsonException when the flag is set.
Decide what malformed input means
Throwing is only half the design. A function that wraps decoding should state its contract. Common choices are:
- let the
JsonExceptionpropagate, so the caller decides; - return a caller-supplied default;
- return an empty array when you only accept lists or objects.
The third example picks the last option. It also checks the type, because syntactically valid JSON such as 7 is not a list.
function decodeList(string $json): array
{
try {
$value = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($value) ? $value : [];
}
echo json_encode([decodeList('[1,2]'), decodeList('[1,'), decodeList('7')]), PHP_EOL;[[1,2],[],[]]
The output is [[1,2],[],[]]: one good list, one truncated document, and one valid but wrongly typed value.
Edge cases to remember
- An empty string is not valid JSON, so it counts as malformed input.
0,false,[]andnullare all valid documents. Never test the result withempty()or?:to detect failure, or you will discard good data.- Catch
JsonExceptionspecifically rather than a broadThrowable, so unrelated bugs stay visible.
Exercise
Write decodeOrDefault(string $json, mixed $default): mixed. It must decode the JSON text with associative arrays enabled and JSON_THROW_ON_ERROR. When decoding succeeds, return the decoded value exactly, even if it is null, 0, false or an empty array. When a JsonException is thrown, return $default. The tests compare the returned values, and they include a valid null document and an empty string, so a check like $result ?: $default will fail.
Recap
json_decode() signals failure with null by default, which collides with the valid JSON null. Pass JSON_THROW_ON_ERROR, catch JsonException, and return a value that your function's contract defines for bad input. Keep valid falsy results intact.
Functions · Arrays · Types
- Use JSON_THROW_ON_ERROR and define malformed-input return values
- Implement and test a deterministic PHP 8.4 function