json_decode() returns null
json_decode does not throw by default. It returns null and expects you to ask what happened. null is also the correct result for the input null, so the return value on its own genuinely cannot tell you whether anything went wrong — json_last_error() is the only way to know.
How the message appears
PHP does not throw by default, so these strings come from json_last_error_msg() rather than from an exception — or from JsonException if you passed JSON_THROW_ON_ERROR.
What causes it
A genuine syntax error
json_last_error_msg() returns Syntax error, which is all PHP will tell you — there is no position, no line and no column. Paste the document into the validator above to find out where it actually is.
The input was not UTF-8
JSON_ERROR_UTF8, reported as "Malformed UTF-8 characters". Latin-1 text from an older database or a file read with the wrong encoding. Convert first: mb_convert_encoding($s, "UTF-8", "ISO-8859-1").
Nesting deeper than the limit
JSON_ERROR_DEPTH. The third argument defaults to 512 levels. Deeply nested documents need it raised explicitly.
It really was null
The document contained the literal null, decoding succeeded, and the result is correctly null. if ($data === null) treats this valid document as a failure, which is a bug that only shows up on the one input nobody tests.
The fix
Pass the flag and let it throw: json_decode($s, true, 512, JSON_THROW_ON_ERROR) raises JsonException on PHP 7.3 and later, which is almost always what you wanted. Without it, check json_last_error() !== JSON_ERROR_NONE and print json_last_error_msg().
{
'name': 'Ada',
'active': true,
}{
"name": "Ada",
"active": true
}The example travels in the URL fragment, which browsers never send to a server.
Worth knowing
- The second argument matters:
json_decode($s, true)gives associative arrays, and without it you getstdClassobjects. It changes nothing about whether the document is valid, only the shape you get back. - Large integers lose precision by default because they become floats.
JSON_BIGINT_AS_STRINGkeeps them intact — relevant for IDs from APIs that use 64-bit numbers. - The encoding direction has the same silence:
json_encodereturnsfalserather than throwing, and also needsJSON_THROW_ON_ERRORto be useful.