PHP: How do you return JSON?
Two lines and you are done. Almost — because json_encode() can fail, and if
you do not check for that you send an empty body with status 200. The client
then sees no error, just nothing.
The two lines
<?php
header('Content-Type: application/json');
echo json_encode($data);
?>
The charset=utf-8 you often see appended is superfluous. Per RFC 8259 JSON is
always UTF-8, and the application/json media type does not define a charset
parameter at all.
What does matter is that there is no output before the header() —
otherwise you get “headers already sent”. There is a separate article about
that.
The point missing from most answers
json_encode() returns false on error. The most common trigger is invalid
UTF-8 in your data:
return value : false
json_last_error() : 5
json_last_error_msg: Malformed UTF-8 characters, possibly incorrectly encoded
echo false outputs an empty string. So your response is: status 200,
content type application/json, empty body. To the client that looks like a
server error that does not exist — and there is nothing in your log.
Where does invalid UTF-8 come from? Usually the database. A column in latin1,
an import from an old application, text pasted out of Word. One bad record is
enough and the entire response is empty.
Two ways to handle it. Either you want to know about it:
<?php
try {
$json = json_encode($data, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
http_response_code(500);
error_log('JSON error: ' . $e->getMessage());
exit;
}
?>
Or you want the response to go out anyway:
with JSON_INVALID_UTF8_SUBSTITUTE : {"text":"gueltig �1 kaputt"}
JSON_INVALID_UTF8_SUBSTITUTE replaces the broken bytes with the replacement
character U+FFFD. For a list of search results that is the better choice —
better a question mark in the text than no results at all.
I use JSON_THROW_ON_ERROR as the default and deviate only where I have a good
reason.
Array or object?
This is the second thing that causes trouble in practice:
json_encode(['a','b']) : ["a","b"]
after unset($l[1]) : {"0":"a","2":"c"} <- object!
with array_values() : ["a","c"]
A single unset() changes the type of your response. A client expecting an
array and wanting to iterate over it gets an object. In JavaScript .map()
then fails; in a statically typed language the parsing already fails.
And the bug only occurs when something was actually filtered out — so not in your tests with complete data.
Hence: if a list is meant to come out, put array_values() in front. Always.
The reverse case is worth a look too:
json_encode([]) : [] <- empty array
json_encode(new stdClass()) : {} <- empty object
json_encode([], JSON_FORCE_OBJECT): {}
If your interface promises an object and you return an empty array for “no
data”, the type changes. For that case there is JSON_FORCE_OBJECT, or simply
a new stdClass().
Umlauts and slashes
no flags : {"ort":"München","url":"https:\/\/example.com\/a\/b"}
JSON_UNESCAPED_UNICODE : {"ort":"München","url":"https:\/\/example.com\/a\/b"}
JSON_UNESCAPED_SLASHES : {"ort":"München","url":"https://example.com/a/b"}
both : {"ort":"München","url":"https://example.com/a/b"}
Both forms are valid JSON and every parser understands both. So this is not about correctness but about two practical things.
First, size. Escaped, every umlaut takes six bytes instead of two. For a response with a lot of German text that quickly adds 20 percent.
Second, readability. When you look at the response in a browser or a log,
München is considerably more pleasant than München.
I set both flags by default:
<?php
echo json_encode($data,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
?>
The slash escaping, incidentally, exists because </script> would terminate a
script block inside an HTML document. If you write JSON directly into an HTML
page, leave JSON_UNESCAPED_SLASHES out or use JSON_HEX_TAG.
Numbers
A few things that can surprise:
['n' => 1.0] : {"n":1} <- the float is gone
with JSON_PRESERVE_ZERO_FRACTION : {"n":1.0}
['n' => 0.1 + 0.2] : {"n":0.30000000000000004}
1.0 becomes 1. If your client evaluates the type — say because a statically
typed language expects a float there — you need
JSON_PRESERVE_ZERO_FRACTION.
And then there is a flag I explicitly do not recommend:
['id' => '9223372036854775807'] : {"id":"9223372036854775807"}
the same with JSON_NUMERIC_CHECK : {"id":9223372036854775807}
['plz' => '007'] with NUMERIC_CHECK : {"plz":7} <- leading zero gone
JSON_NUMERIC_CHECK converts every numeric-looking string into a number. That
destroys postcodes with a leading zero, article numbers, phone numbers, IBANs
and account numbers. The flag is occasionally recommended for getting “clean”
types — it does more damage than good. If a field is supposed to be a number,
make sure it already is one before json_encode().
Large numbers when decoding
The other direction has a problem of its own:
{"id": 9223372036854775808}
type : float
value : 9.223372036854776E+18 <- precision lost
JSON_BIGINT_AS_STRING : '9223372036854775808' (string)
Numbers above PHP_INT_MAX become floats, and with that the precision is gone.
You meet this with IDs from systems using 64-bit snowflake IDs — Discord, X and
some others. With JSON_BIGINT_AS_STRING the value survives:
<?php
$data = json_decode($json, true, 512, JSON_BIGINT_AS_STRING);
?>
Detecting errors when decoding
Here too a close look pays off:
'null' -> NULL error=0 (No error)
'{"a":1}' -> array error=0 (No error)
'{a:1}' -> NULL error=4 (Syntax error)
'' -> NULL error=4 (Syntax error)
The first line is the point: json_decode('null') returns null and is
entirely successful doing so. null is valid JSON, after all. So a test for
=== null does not distinguish between “broken JSON” and “the answer was
null”.
So either check json_last_error() or go straight to:
<?php
try {
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
// invalid JSON
}
?>
The third parameter you have to carry along for that is the maximum nesting depth:
depth 512 (default) : true
depth 3 : NULL error=Maximum stack depth exceeded
For data from outside, a lower value is a cheap safeguard against deliberately deeply nested documents.
Emitting your own objects
Finally something you set up once and then never touch again:
without JsonSerializable : {}
with JsonSerializable : {"betrag":4.99,"waehrung":"EUR"}
json_encode() only takes the public properties along. So with a class
using private or protected you get an empty object — without any error
message at all.
The solution is the JsonSerializable interface:
<?php
class Price implements JsonSerializable {
public function __construct(private int $cents) {}
public function jsonSerialize(): array {
return ['amount' => $this->cents / 100, 'currency' => 'EUR'];
}
}
?>
That way you decide yourself what goes into the response. It is also the right place to keep out things that are nobody’s business — password hashes, internal IDs, database timestamps.
Summary
header('Content-Type: application/json')— thecharsetis superfluous.json_encode()can returnfalse; without a check you send an empty body with status 200.JSON_THROW_ON_ERRORas the default,JSON_INVALID_UTF8_SUBSTITUTEwhen the response should go out regardless.array_values()before output, otherwise the array becomes an object.JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHESfor shorter, more readable output.- Do not use
JSON_NUMERIC_CHECK— it destroys postcodes and IBANs. - When decoding,
JSON_BIGINT_AS_STRINGfor large IDs. json_decode('null')succeeds —nullalone is no good as an error test.- Your own classes need
JsonSerializable, otherwise every non-public property is missing.
About Netcup (advertisement)
The German host Netcup offers, among other things, affordable and powerful web hosting packages, KVM-based root servers and dedicated servers. With our voucher codes you can save even more (6€ off your first order, 30% off all KVM-based root servers, ...).