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') — the charset is superfluous.
  • json_encode() can return false; without a check you send an empty body with status 200.
  • JSON_THROW_ON_ERROR as the default, JSON_INVALID_UTF8_SUBSTITUTE when the response should go out regardless.
  • array_values() before output, otherwise the array becomes an object.
  • JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES for shorter, more readable output.
  • Do not use JSON_NUMERIC_CHECK — it destroys postcodes and IBANs.
  • When decoding, JSON_BIGINT_AS_STRING for large IDs.
  • json_decode('null') succeeds — null alone 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, ...).