Emitting JSON: Set the Header, Encode Once, Handle the Failure
A PHP endpoint returns json_encode($rows) and the client reports an empty response — or a proxy log shows the payload arriving as text/html. Both failures come from the two halves of a JSON response, the header and the body, and PHP makes neither half automatic.
The header comes first, because headers can only be set before any output — the rule established in Choosing HTTP Status Codes:
header('Content-Type: application/json; charset=utf-8');
http_response_code(200);
echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
Skip that header and the body arrives with PHP's default text/html; charset=UTF-8. Permissive clients parse it anyway, strict mobile clients reject it in their JSON layer, and a cache keys on the content type it saw. The charset=utf-8 matters too: without it a proxy is entitled to reinterpret the bytes, and non-ASCII names mangle on the way through.
json_encode is where the second class of failure lives. It returns false on error, and echo false prints nothing — giving you a 200 with an empty body. The causes are specific and worth knowing by heart:
- invalid UTF-8 in a string (a byte sequence copied from a legacy table)
INF,NAN, or a resource hiding in the data- recursion, or nesting deeper than the depth limit of 512
Since PHP 7.3 the clean answer is JSON_THROW_ON_ERROR, which turns the silent false into a JsonException you catch at the boundary:
try {
$body = json_encode($payload, JSON_THROW_ON_ERROR
| JSON_UNESCAPED_SLASHES
| JSON_UNESCAPED_UNICODE
| JSON_PRESERVE_ZERO_FRACTION);
} catch (JsonException $e) {
error_log('json_encode failed: ' . $e->getMessage());
http_response_code(500);
exit;
}
echo $body;
On PHP older than 7.3 — or when an exception must not escape the response layer — the same guard written explicitly:
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);
if ($body === false) {
error_log('json_encode failed: ' . json_last_error_msg());
http_response_code(500);
exit;
}
echo $body;
json_last_error_msg() names the actual cause: Malformed UTF-8 characters, possibly correctly encoded points at dirty data from the database, while Type is not supported points at the resource or INF you left in the payload. The JSON_THROW_ON_ERROR version is preferable because it cannot be forgotten halfway down a long function; the explicit check is the same promise kept by hand.
The flags worth carrying in your toolbox, with their sharp edges:
| Flag | Effect | Watch out |
|---|---|---|
JSON_PRETTY_PRINT |
Indented, readable output | Bloats payloads; dev tooling only |
JSON_UNESCAPED_SLASHES |
\/ becomes / |
Cosmetic and safe |
JSON_UNESCAPED_UNICODE |
Real é instead of \u00e9 |
Safe with charset=utf-8 |
JSON_NUMERIC_CHECK |
Numeric-looking strings become numbers | Corrupts "007", zip codes, IDs — avoid |
JSON_PRESERVE_ZERO_FRACTION |
1.0 stays 1.0, not 1 |
Keeps float intent visible |
JSON_INVALID_UTF8_SUBSTITUTE |
Bad bytes → U+FFFD | Salvages a response instead of failing |
JSON_THROW_ON_ERROR |
Throws JsonException |
Pair it with a 500 handler |
JSON_NUMERIC_CHECK is the one people enable "because the IDs look cleaner" and then discover an order reference rendered as 1.7E+15 in a spreadsheet export. If a value has leading zeros, digits beyond 15–16 places, or meaning as a string, it is not a number — it is an identifier.
One habit ties it together: encode once, print once, then stop. A stray newline emitted after the closing brace by an included file makes strict client parsers fail on trailing content, even though the JSON itself was perfect. That single-echo discipline is also the strongest argument for funneling every response through one helper — which is exactly the structure Routing Requests in a Front Controller builds around.
Reading a JSON Body: Why $_POST Stays Empty and assoc=true Matters
You POST {"title":"Hello"} with Content-Type: application/json, dump $_POST, and get []. Nothing is broken: PHP populates $_POST only from urlencoded and multipart bodies. A JSON body is data you read and parse yourself, and the parsing hides three decisions.
First, read the raw body and size-check it before anything else:
$claimed = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($claimed > 1024 * 1024) {
http_response_code(413); // Payload Too Large
exit;
}
$raw = file_get_contents('php://input');
if ($raw === '') {
http_response_code(400);
echo json_encode(['error' => ['code' => 'empty_body',
'message' => 'A JSON body is required']]);
exit;
}
Checking CONTENT_LENGTH first is cheap defense: it rejects a 400 MB body before you allocate anything to parse it. For multipart uploads, php://input is documented as unavailable — file fields still arrive through $_FILES, the shape covered in Reading Requests with Superglobals.
Second, decide the content type. A client sending text/plain or application/xml deserves 415 — a clear signal — rather than a confusing parse failure:
$type = $_SERVER['CONTENT_TYPE'] ?? '';
if (!str_starts_with($type, 'application/json')) {
http_response_code(415);
exit;
}
Third, decode. The second argument of json_decode chooses what PHP builds for you, and this is the decision teams pick once and live with:
try {
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
http_response_code(400); // unparseable → Bad Request
echo json_encode(['error' => ['code' => 'malformed_json',
'message' => $e->getMessage()]]);
exit;
}
$title = $data['title'] ?? null; // assoc: true → arrays
With assoc: true you get ['title' => 'Hello']. With false — the default — you get a stdClass, and the first $data['key'] access fatals with Cannot use object of type stdClass as array. Both styles are defensible: array-first code composes with filter_*, array_map, and spread syntax, while object decoding pairs with typed DTOs and constructor property promotion. What is not defensible is mixing them per file, which is how the fatals start. json_decode($raw, true) — associative arrays — is the pragmatic default for hand-rolled endpoints, because everything downstream in PHP already speaks arrays.
Why PHP will not do this for you: populating $_POST happens in the SAPI layer for the two browser encodings only, because those are the two the platform defines. JSON is opt-in — and that is fine, because the boundary is where you want control. Parse once, validate immediately, then pass typed values inward: a handler receiving a validated $title cannot accidentally act on the raw body, and the 400 decision is already made before business logic runs.
The remaining parameters and edge cases have concrete failure modes:
| Input | What happens | Answer with |
|---|---|---|
| Nesting deeper than 512 | JSON_ERROR_DEPTH |
400 |
Number above PHP_INT_MAX |
Becomes float, precision lost |
JSON_BIGINT_AS_STRING, or send IDs as strings |
| Trailing garbage after JSON | JSON_ERROR_SYNTAX |
400 |
| Duplicate keys in one object | Last key silently wins | Reject in strict APIs |
NaN / Infinity literals |
Not valid JSON, parse error | 400 |
The float row is the quiet one: json_decode('8241199347110543291') does not return the number you sent. Anything a client must round-trip exactly — snowflake ids, monetary amounts in cents — crosses the wire as a string, and that is a payload design decision rather than a decoding one. Designing the shapes clients depend on — error bodies, envelopes, pagination — is the next thing to nail down before the URL structure arrives in Routing Requests in a Front Controller.
Error Bodies, Envelopes, and the Shapes Clients Depend On
Encoding gives you valid JSON; design gives clients something stable to code against. The first decision shapes everything else: do you return the resource at the top level, or wrap it in an envelope?
{ "id": 42, "title": "Hello", "tags": ["php"] }
{ "data": { "id": 42, "title": "Hello", "tags": ["php"] },
"meta": { "request_id": "8f1c2e" } }
A top-level resource makes GET /articles/42 self-describing and curl-friendly. The envelope costs one hop ($json['data']) but buys uniform room for meta, request ids, and pagination across every endpoint — including errors, where the top-level shape must change anyway:
{ "error": { "code": "validation_failed",
"message": "Fix the fields below",
"fields": { "email": "Must be a valid email address" } } }
Whichever you pick, three rules keep clients alive.
Every response gets the JSON header — not just the happy path. The most common bug in this area: header('Content-Type: application/json') lives only inside the success branch, so the 422 and 500 paths emit HTML or bare text, and the client crashes in its parser instead of displaying your message. Set the header once at the top of your response helper, then choose the status. The status choices themselves are catalogued in Choosing HTTP Status Codes.
Identifiers that must not drift cross the wire as strings. JavaScript reads every JSON number as a double, so anything above 2^53 − 1 (9007199254740991) loses precision in the browser before your code ever sees it:
{"id": 8241199347110543291} → 8241199347110543300 in a JS console
{"id": "8241199347110543291"} → exact round-trip, every language
Snowflake ids, ledger amounts in cents, and long account numbers belong in the second form. Money also avoids the 1.9999999999999998 class of surprises when it travels as integer minor units.
Dates are ISO 8601 with an offset, and optional fields are absent rather than null. gmdate('c') produces 2026-03-14T09:21:00+00:00, which every mainstream language parses without a format table — as opposed to Y-m-d H:i:s, which parses only where someone remembered the assumption. On null: omit an optional field when it simply does not apply, but do send null when it carries intent ("clear this value"). In PATCH semantics those two readings are different operations, and collapsing them destroys information.
Error codes are a switch statement; messages are for humans. The code field is part of your public contract — clients branch on validation_failed, rate_limited, version_retired — while the message may be reworded, fixed, or localized at will. Renaming a code breaks exactly as much as removing a field. Attach a request_id to every error body (the one you already wrote to the log in the 500 handler), and a support ticket becomes a single grep instead of a reproduction session.
Pagination deserves the same deliberate shape:
{ "data": [ ...20 items... ],
"meta": { "per_page": 20, "next_cursor": "b2Zmc2V0OjIw" },
"links": { "next": "/v1/articles?cursor=b2Zmc2V0OjIw" } }
Cursor pagination — an opaque token, no page numbers — is what lets you change the storage strategy later without breaking a single client; page-number pagination leaks an assumption about how the data is counted. Notice too that the example already carries /v1 in the URL: payloads and versions evolve together.
Consistency is the last rule: one naming style everywhere (snake_case in, snake_case out), stable error code values that clients may switch on, and human-readable message fields that are never parsed. Treat the whole surface as a contract, because that is what it becomes the day a client ships against it. Contracting the URL side of that deal — patterns, methods, and dispatch — is Routing Requests in a Front Controller.
Quiz
1. json_encode($data) returns false and your endpoint answers 200 with an empty body. Which addition prevents that failure mode?
2. A POST arrives with Content-Type: application/json. What does $_POST contain, and where does the data come from?
3. json_decode($raw, true) and json_decode($raw) run against the same body. What differs in the result?
4. JavaScript clients must read back an 18-digit account id exactly. How should it cross the wire?
Flashcards
Question
Which header must precede the JSON body, and what happens if only the success path sets it?
Click to reveal answer
Answer
Content-Type: application/json; charset=utf-8, set with header() before any output. If only the success branch sets it, 4xx/5xx bodies arrive as text/html or bare text and strict clients crash in their parser instead of showing your message — set it once in the response helper for every status.
Question
Why can json_encode return false, and what is the modern guard?
Click to reveal answer
Answer
Invalid UTF-8, INF/NAN, a resource, recursion, or nesting beyond depth 512 all fail encoding; echo false then produces a 200 with an empty body. Pass JSON_THROW_ON_ERROR (PHP 7.3+) so a JsonException surfaces — catch it, log the message, and answer 500. Without it, check the false return and read json_last_error_msg().
Question
What does json_decode's second argument control, and what should a hand-rolled API pick?
Click to reveal answer
Answer
assoc: true returns associative arrays all the way down; false, the default, returns stdClass objects. Hand-rolled endpoints usually pick true because filter_*, array_map, and spread syntax all operate on arrays. The real rule is one policy per codebase — mixing them per file is how 'Cannot use object of type stdClass as array' fatals start.
Question
Which statuses gate a JSON body before you even parse it, and which after?
Click to reveal answer
Answer
Before: 413 Payload Too Large when CONTENT_LENGTH exceeds your cap (reject before allocating), 415 Unsupported Media Type when CONTENT_TYPE isn't application/json. After: 400 for decode failures (JSON_THROW_ON_ERROR, empty body, depth overflow), then 422 when the decoded values fail validation.
Question
Three payload rules that prevent silent client-side corruption?
Click to reveal answer
Answer
1) Ids above 2^53-1 cross as strings — JavaScript reads JSON numbers as doubles. 2) Dates are ISO 8601 with an offset (gmdate('c')), never locale formats. 3) Optional fields are absent rather than null, unless null means 'clear this value' — in PATCH those are different operations.
Revision Notes
Key Takeaways
- 1. Set Content-Type: application/json; charset=utf-8 before output, encode once with deliberate flags, then exit — on error paths too
- 2. json_encode can return false (bad UTF-8, INF/NAN, recursion, depth); JSON_THROW_ON_ERROR converts the silent empty 200 into a catchable JsonException
- 3. PHP never parses JSON into $_POST — read php://input after gating on CONTENT_LENGTH (413) and CONTENT_TYPE (415), then decode with assoc: true
- 4. JSON_NUMERIC_CHECK corrupts identifiers like '007'; values that must round-trip go as strings, money as integer minor units
- 5. A stable contract means one error envelope with stable codes, cursor pagination, ISO 8601 dates, and the JSON header on success and failure alike
Interview Tips
- • "json_encode failed in production — what do you see and what do you do?" — an empty 200 body; causes are malformed UTF-8 from the database, INF/NAN, or depth overflow; fix with JSON_THROW_ON_ERROR, log the message, return a generic 500
- • "Why is $_POST empty?" — the Content-Type is application/json and PHP only parses urlencoded/multipart into superglobals; read php://input; add that php://input is unavailable for multipart so uploads still use $_FILES
- • "How do you return validation errors?" — 422 with a stable code and a per-field map, with the JSON header set on the error path as well; clients branch on code, humans read message
- • "How do you avoid precision loss in ids?" — JavaScript doubles lose integers above 2^53-1; send 18-digit ids as strings; flag JSON_NUMERIC_CHECK and JSON_PRETTY_PRINT as the two payload footguns
Cheat Sheet
Building JSON Endpoints
Response skeleton (headers before output):
header('Content-Type: application/json; charset=utf-8');
http_response_code(200);
echo json_encode($payload, JSON_THROW_ON_ERROR
| JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION);
Encode flags:
| Flag | Use | Trap |
|---|---|---|
JSON_THROW_ON_ERROR |
turn false into JsonException |
pair with a 500 handler |
JSON_PRETTY_PRINT |
dev/debug only | bloats payloads |
JSON_UNESCAPED_SLASHES / _UNICODE |
readable output | safe with charset=utf-8 |
JSON_NUMERIC_CHECK |
— | avoid: corrupts "007", IDs |
JSON_INVALID_UTF8_SUBSTITUTE |
salvage dirty data | masks encoding bugs |
Failures: invalid UTF-8 · INF/NAN · recursion · depth > 512. Without the guard: json_encode → false → echo false → empty 200. Fallback check: json_last_error_msg().
Reading a request: $_POST stays empty for JSON → read php://input.
if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 1_048_576) { http_response_code(413); exit; }
if (!str_starts_with($_SERVER['CONTENT_TYPE'] ?? '', 'application/json')) { http_response_code(415); exit; }
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); // assoc: true → arrays
| Decode failure | Status |
|---|---|
| syntax / empty body | 400 |
| depth > 512 | 400 |
| wrong content type | 415 |
| valid JSON, invalid values | 422 |
Payload rules: ids > 2^53−1 as strings (JS doubles) · dates ISO 8601 (gmdate('c')) · optional fields absent, null only when it means "clear" · cursor pagination (next_cursor, opaque) · stable error code for branching, message for humans · JSON header on error paths too.
{ "error": { "code": "validation_failed",
"message": "Fix the fields below",
"fields": { "email": "Must be a valid email address" } } }