Building JSON Responses
A JSON response is more than json_encode(). It needs the right status code, Content-Type, body shape, and error handling.
Clients become easier to write when your API uses consistent response conventions across endpoints.
Success response shape
<?php
declare(strict_types=1);
$response = [
'data' => ['id' => 123, 'name' => 'Notebook'],
'meta' => ['request_id' => 'req_abc123'],
];
echo json_encode($response, JSON_THROW_ON_ERROR) . PHP_EOL;
// Prints:
// {"data":{"id":123,"name":"Notebook"},"meta":{"request_id":"req_abc123"}}
In a real web response, also send Content-Type: application/json and 200 OK.
Create a small response helper
A helper can keep status, headers, and encoded body together.
<?php
declare(strict_types=1);
/**
* @param array<string, mixed> $body
* @return array{status: int, headers: array<string, string>, body: string}
*/
function jsonResponse(array $body, int $status = 200): array
{
return [
'status' => $status,
'headers' => ['Content-Type' => 'application/json'],
'body' => json_encode($body, JSON_THROW_ON_ERROR),
];
}
$response = jsonResponse(['data' => ['id' => 123]], 200);
echo $response['status'] . ' ' . $response['headers']['Content-Type'] . ' ' . $response['body'] . PHP_EOL;
// Prints:
// 200 application/json {"data":{"id":123}}
Frameworks already provide response helpers. The point is to understand what they produce.
Error response shape
Use a predictable error structure.
<?php
declare(strict_types=1);
$error = [
'errors' => [
[
'code' => 'validation_failed',
'field' => 'email',
'message' => 'Enter a valid email address.',
],
],
];
echo json_encode($error, JSON_THROW_ON_ERROR) . PHP_EOL;
// Prints:
// {"errors":[{"code":"validation_failed","field":"email","message":"Enter a valid email address."}]}
Do not return raw exception messages from production APIs. Convert internal failures into safe messages and log details internally.
Match body and status code
The status code and body should tell the same story.
200 OK: returned an existing resource or result.201 Created: created a resource; include the created resource or location.204 No Content: success with no body.400 Bad Request: malformed request, such as invalid JSON.401 Unauthorized: missing or invalid authentication.403 Forbidden: authenticated but not allowed.404 Not Found: resource not found.422 Unprocessable Content: validation failed.
For 204 No Content, do not send a JSON body.
<?php
declare(strict_types=1);
/**
* @return array{status: int, headers: array<string, string>, body: string}
*/
function noContentResponse(): array
{
return ['status' => 204, 'headers' => [], 'body' => ''];
}
$response = noContentResponse();
echo $response['status'] . ' body length ' . strlen($response['body']) . PHP_EOL;
// Prints:
// 204 body length 0
Avoid accidental output
If a PHP warning, debug var_dump(), or stray whitespace is sent before your JSON response, the client may receive invalid JSON.
Keep API endpoints free from debug output. Use logs, not printed debugging text.
Encoding failures
json_encode() can fail. Use JSON_THROW_ON_ERROR and handle the exception at the framework or API boundary. If your application cannot encode its own response, that is normally a server error, not a client validation error.
Keep Response Construction At The HTTP Boundary
Application services should return domain results, DTOs, or explicit outcome objects. A controller or response mapper turns that result into HTTP status, headers, and JSON. If domain code calls header() or prints JSON directly, it becomes difficult to reuse from a command, queue worker, or test.
<?php
declare(strict_types=1);
final readonly class ProductView
{
public function __construct(
public string $id,
public string $name,
public int $pricePennies,
) {
}
}
function productResponseData(ProductView $product): array
{
return [
'id' => $product->id,
'name' => $product->name,
'price_pennies' => $product->pricePennies,
];
}
$product = new ProductView('prod_123', 'Notebook', 1299);
$body = json_encode(['data' => productResponseData($product)], JSON_THROW_ON_ERROR);
echo $body . PHP_EOL;
// Prints:
// {"data":{"id":"prod_123","name":"Notebook","price_pennies":1299}}
Framework response objects already provide this boundary. In Laravel, Symfony, Slim, and PSR-7 stacks, return the framework's response instead of manually calling http_response_code() and header() throughout controllers.
Headers Complete The Response
Content-Type: application/json tells the client how to interpret the body. Additional headers communicate behavior that cannot be inferred from the JSON:
Locationidentifies a newly created resource after201 CreatedCache-Controlstates whether and how a response may be cachedETagorLast-Modifiedsupports conditional requestsVarytells shared caches which request headers affect representationRetry-Aftercan guide clients after429or503- a request or correlation ID helps support trace a failure
<?php
declare(strict_types=1);
function createdProductResponse(string $id, string $name): array
{
return [
'status' => 201,
'headers' => [
'Content-Type' => 'application/json',
'Location' => '/api/products/' . rawurlencode($id),
'Cache-Control' => 'no-store',
],
'body' => json_encode([
'data' => ['id' => $id, 'name' => $name],
], JSON_THROW_ON_ERROR),
];
}
$response = createdProductResponse('prod 123', 'Notebook');
echo $response['headers']['Location'] . PHP_EOL;
// Prints:
// /api/products/prod%20123
Do not put secrets in a request ID or echo an untrusted incoming ID without validation. Correlation identifiers are operational metadata, not authentication.
Bodyless Responses Need Special Handling
204 No Content and 304 Not Modified do not carry a message body. A response to HEAD sends the headers that a corresponding GET would send but omits body bytes.
Do not run every response through one helper that always emits {} or null. Those are JSON bodies and violate the intended bodyless response.
A 204 may still include relevant headers, but framework or server behavior can remove headers that are meaningless for that status. Test the actual HTTP output rather than only a helper array.
Encoding Options Must Be Deliberate
JSON_THROW_ON_ERROR converts encoding failures into JsonException, which is safer than silently returning false. Other useful flags depend on the contract:
JSON_PRESERVE_ZERO_FRACTIONkeeps12.0visibly distinct from12when that distinction mattersJSON_UNESCAPED_SLASHESimproves readability of URLs but does not change their valueJSON_UNESCAPED_UNICODEkeeps readable Unicode instead of escape sequencesJSON_INVALID_UTF8_SUBSTITUTEreplaces invalid bytes, which may hide upstream data corruption
Do not add JSON_INVALID_UTF8_SUBSTITUTE automatically. For a public API, silently changing a customer's name may be worse than failing and fixing invalid storage. Use it only with a documented reason.
<?php
declare(strict_types=1);
$value = ['ratio' => 12.0, 'url' => 'https://example.test/products'];
echo json_encode(
$value,
JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_SLASHES,
) . PHP_EOL;
// Prints:
// {"ratio":12.0,"url":"https://example.test/products"}
JSON flags do not replace a response schema. Clients still need documented field types and nullability.
Handle Encoding Failure Once
If response encoding fails, the endpoint cannot send its intended success response. Treat that as an internal server error and log the exception with safe context.
The fallback must be simple and known to encode. Avoid placing the original invalid data or exception text inside it, because that can leak information or fail again.
<?php
declare(strict_types=1);
/** @return array{status: int, headers: array<string, string>, body: string} */
function encodeApiResponse(array $payload): array
{
try {
return [
'status' => 200,
'headers' => ['Content-Type' => 'application/json'],
'body' => json_encode($payload, JSON_THROW_ON_ERROR),
];
} catch (JsonException) {
return [
'status' => 500,
'headers' => ['Content-Type' => 'application/json'],
'body' => '{"errors":[{"code":"response_encoding_failed","message":"The response could not be created."}]}',
];
}
}
$response = encodeApiResponse(['data' => ["invalid\xB1utf8"]]);
echo $response['status'] . PHP_EOL;
echo $response['body'] . PHP_EOL;
// Prints:
// 500
// {"errors":[{"code":"response_encoding_failed","message":"The response could not be created."}]}
A framework-wide exception handler is usually the correct place for this policy. It can log through the configured logger and avoid partially emitted responses.
Do Not Leak Exceptions
Catch expected domain outcomes near the controller and map them to documented statuses. Let unexpected exceptions reach a centralized handler that records the stack trace internally and returns a generic error.
<?php
declare(strict_types=1);
final class ProductNotFound extends RuntimeException
{
}
function publicError(Throwable $exception, string $requestId): array
{
if ($exception instanceof ProductNotFound) {
return [
'status' => 404,
'body' => ['errors' => [[
'code' => 'product_not_found',
'message' => 'Product not found.',
'request_id' => $requestId,
]]],
];
}
return [
'status' => 500,
'body' => ['errors' => [[
'code' => 'internal_error',
'message' => 'The request could not be completed.',
'request_id' => $requestId,
]]],
];
}
Do not return $exception->getMessage() for unknown exceptions. It may contain SQL, filesystem paths, access tokens, hostnames, or personal data.
Accidental Output Corrupts The Body
A JSON response must contain only the intended JSON bytes. Common corruption sources include:
var_dump(),print_r(),echo, ordd()left in code- warnings and notices displayed in production
- a UTF-8 byte-order mark before
<?php - whitespace outside PHP tags in included files
- third-party code writing to standard output
- framework debug pages replacing API errors with HTML
Disable display of production errors and send them to logs. Prefer files containing only PHP code without a closing ?> tag, avoiding accidental trailing whitespace.
Output buffering can sometimes capture legacy output, but it should not become a permanent substitute for fixing the component. Tests should decode the exact response body and fail when any prefix or suffix appears.
Conditional Responses Save Work And Bandwidth
An ETag identifies a representation version. The client can send If-None-Match; if the current tag matches, the server returns 304 Not Modified with no body.
<?php
declare(strict_types=1);
/** @return array{status: int, headers: array<string, string>, body: string} */
function productConditionalResponse(array $data, ?string $ifNoneMatch): array
{
$body = json_encode(['data' => $data], JSON_THROW_ON_ERROR);
$etag = '"' . hash('sha256', $body) . '"';
$headers = [
'Content-Type' => 'application/json',
'ETag' => $etag,
'Cache-Control' => 'private, max-age=0, must-revalidate',
];
if ($ifNoneMatch === $etag) {
return ['status' => 304, 'headers' => $headers, 'body' => ''];
}
return ['status' => 200, 'headers' => $headers, 'body' => $body];
}
$first = productConditionalResponse(['id' => 'prod_123'], null);
$second = productConditionalResponse(['id' => 'prod_123'], $first['headers']['ETag']);
echo $first['status'] . ' ' . strlen($first['body']) . PHP_EOL;
echo $second['status'] . ' ' . strlen($second['body']) . PHP_EOL;
// Prints:
// 200 26
// 304 0
A production cache policy must consider authorization and user-specific data. Do not mark private responses as publicly cacheable without a deliberate design.
Large Responses Need A Different Strategy
Building one giant PHP array and then encoding it duplicates memory: objects or rows occupy memory, the array occupies memory, and the final JSON string occupies memory. For large exports, use pagination, asynchronous jobs, or a streaming encoder designed for correct JSON framing.
Do not stream a normal endpoint merely to avoid setting limits. Streaming complicates error handling because the status and some body bytes may already be sent when a later record fails. The dedicated large-JSON lesson covers that tradeoff.
Response Compression Belongs To Infrastructure
Web servers, reverse proxies, and CDNs usually handle gzip or Brotli compression more reliably than application PHP. They can negotiate Accept-Encoding, set Content-Encoding, and maintain cache variants.
Avoid manually compressing individual controllers unless the deployment architecture requires it. Double compression, incorrect content length, and missing Vary: Accept-Encoding can corrupt or poison caches.
Test The Real Response Object
A useful endpoint test checks:
- status code
Content-Type- required headers such as
Location, cache policy, or request ID - body absence for
204and304 - decodable JSON for body-bearing responses
- exact top-level envelope
- field types and sensitive-field absence
- safe error output when encoding or application code fails
Do not assert one long JSON string when object key order is irrelevant. Decode and assert the structure, while retaining at least one lower-level test for invalid prefixes and response bytes.
What To Check In A Project
Check that controllers return framework response objects and do not print output directly.
Check that JSON responses set the intended content type, status, cache policy, and operation-specific headers.
Check that bodyless statuses and HEAD requests emit no body.
Check that one centralized handler maps unexpected exceptions and encoding failures to safe errors.
Check that debug output and displayed warnings cannot corrupt JSON.
Check that large responses, compression, conditional requests, and user-specific caching are handled deliberately.
Check endpoint tests against the status, headers, and exact response body.
What You Should Be Able To Do
After this lesson, you should be able to construct JSON at an HTTP boundary, choose body-bearing and bodyless statuses, add headers such as Location, Cache-Control, and ETag, and select JSON encoding flags deliberately.
You should also be able to contain encoding failures, map exceptions without leaking internals, prevent accidental output, avoid loading unbounded responses into memory, and test the actual response object that clients receive.
Pair these response-building rules with API Status Code Design so each domain outcome maps to one documented HTTP contract.
JSON Responses Need A Stable Shape
A JSON response is part of the API contract. Clients depend on field names, types, nullability, error shape, pagination structure, and status codes. PHP can produce JSON easily, but a useful API response requires decisions before json_encode() runs.
Start with the resource or operation outcome. Decide which fields are public, which are derived, which are optional, and which are sensitive. Avoid returning database rows directly. A row may contain internal IDs, flags, soft-delete markers, password hashes, provider references, or fields whose names do not belong in the public API.
Encoding Correctly
Use json_encode() with JSON_THROW_ON_ERROR so encoding failures become explicit exceptions instead of silent false. Set the Content-Type header to application/json; charset=utf-8. Ensure the response body is valid UTF-8; JSON strings are Unicode text, and invalid byte sequences should be rejected or normalized at the boundary.
Numeric values need care. Large identifiers may exceed JavaScript's safe integer range, so represent them as strings when clients must preserve exact identity. Money should not be sent as an ambiguous float. Prefer integer minor units with currency or a documented decimal string, depending on the API contract.
Do not use JSON_NUMERIC_CHECK casually. It can turn strings such as phone numbers, postal codes, and identifiers into numbers, losing leading zeros or precision. Convert values deliberately before encoding.
Success Response Design
For a single resource, return one object with predictable fields. For collections, include pagination metadata or links according to the API style. Keep ordering documented when it matters. If the server omits fields based on permissions, document whether the field is absent or present with null; clients treat those differently.
201 Created responses should include enough information for the client to locate the new resource. 204 No Content should be used only when no body is sent. If a client needs updated server state after a mutation, return 200 or 201 with a representation instead of forcing an immediate follow-up request.
Error Response Design
Errors should have a stable envelope. A useful shape includes a machine-readable code, a human-safe message, optional field errors, and a correlation or request identifier. The public message should not contain stack traces, SQL, provider secrets, filesystem paths, or raw exception messages.
Validation errors deserve structure. Field paths, rejected values where safe, and rule names can help clients display corrections. Authorization failures should not reveal private resource details. Rate-limit and temporary-unavailable errors should include retry guidance when the server can provide it.
The status code and JSON error code should agree. A 401 body should not describe validation. A 409 body should identify the conflicting state or version where safe. A 500 body should be generic publicly but detailed enough in logs for operators.
Serialization Boundaries
Use response DTOs, presenters, resources, or explicit arrays to create JSON. The exact pattern matters less than the boundary: domain objects and ORM entities should not accidentally define the API. Explicit mapping lets the application rename fields, hide internal data, format dates, and choose numeric representations.
Dates should use one documented format, usually an ISO 8601/RFC 3339 style string with timezone. Booleans should be booleans, not 0/1 strings. Empty collections should normally be arrays, not null, unless null has a specific meaning.
Testing JSON Responses
Test status, headers, and body together. Decode JSON in tests and assert types, not just substrings. Include missing optional fields, null values, empty arrays, large IDs, Unicode text, validation errors, and permission-filtered responses.
Compare examples in OpenAPI or documentation with real responses. Documentation drift is common when response arrays are assembled by hand in controllers. A schema test or snapshot can help, but snapshots should be reviewed for meaning rather than accepted blindly.
After this lesson, you should be able to build JSON responses with explicit public shapes, safe encoding, consistent success and error envelopes, correct status/header behavior, deliberate numeric and date representations, and tests that verify the contract clients actually consume.
Practice
Task: Build Response Helpers
Write a small PHP script with helpers for JSON success, JSON validation error, and no-content responses.
Requirements
- Use
declare(strict_types=1);. - Return arrays containing
status,headers, andbody. - Success responses must use a
datakey. - Validation errors must use an
errorskey. - No-content responses must have an empty body.
- Encode JSON with
JSON_THROW_ON_ERROR. - Print all three response summaries.
Check Your Work
Run the script and confirm the 204 response does not contain JSON.
Show solution
This solution keeps status, headers, and body together so callers cannot forget one part of the response.
<?php
declare(strict_types=1);
/**
* @param array<string, mixed> $data
* @return array{status: int, headers: array<string, string>, body: string}
*/
function successResponse(array $data, int $status = 200): array
{
return [
'status' => $status,
'headers' => ['Content-Type' => 'application/json'],
'body' => json_encode(['data' => $data], JSON_THROW_ON_ERROR),
];
}
/**
* @return array{status: int, headers: array<string, string>, body: string}
*/
function validationErrorResponse(string $field, string $message): array
{
return [
'status' => 422,
'headers' => ['Content-Type' => 'application/json'],
'body' => json_encode([
'errors' => [
['field' => $field, 'message' => $message],
],
], JSON_THROW_ON_ERROR),
];
}
/**
* @return array{status: int, headers: array<string, string>, body: string}
*/
function noContentResponse(): array
{
return ['status' => 204, 'headers' => [], 'body' => ''];
}
$responses = [
successResponse(['id' => 123, 'name' => 'Notebook']),
validationErrorResponse('name', 'Name is required.'),
noContentResponse(),
];
foreach ($responses as $response) {
echo $response['status'] . ' body length ' . strlen($response['body']) . PHP_EOL;
}
// Prints:
// 200 body length 37
// 422 body length 63
// 204 body length 0
Why This Works
The helpers enforce consistent response shapes. The 204 case proves successful responses do not always need JSON bodies.
Practice: Handle Encoding Failure
Build a response encoder that fails safely when application data contains invalid UTF-8.
Task
Write safeJsonResponse(array $payload): array returning status, headers, and body. It should:
- encode normal payloads with
JSON_THROW_ON_ERROR - return status
200for successful encoding - catch
JsonException - return status
500and a fixed, known-valid JSON error body - never include the invalid value or exception message in the public fallback
Demonstrate one valid and one invalid payload.
Show solution
The fallback is a fixed JSON string, so it does not attempt to re-encode the data that caused the original failure.
<?php
declare(strict_types=1);
function safeJsonResponse(array $payload): array
{
$headers = ['Content-Type' => 'application/json'];
try {
return [
'status' => 200,
'headers' => $headers,
'body' => json_encode($payload, JSON_THROW_ON_ERROR),
];
} catch (JsonException) {
return [
'status' => 500,
'headers' => $headers,
'body' => '{"errors":[{"code":"response_encoding_failed","message":"The response could not be created."}]}',
];
}
}
$valid = safeJsonResponse(['data' => ['name' => 'Notebook']]);
$invalid = safeJsonResponse(['data' => ['name' => "bad\xB1text"]]);
echo $valid['status'] . ' ' . $valid['body'] . PHP_EOL;
echo $invalid['status'] . ' ' . $invalid['body'] . PHP_EOL;
// Prints:
// 200 {"data":{"name":"Notebook"}}
// 500 {"errors":[{"code":"response_encoding_failed","message":"The response could not be created."}]}
A real centralized exception handler should also log the JsonException with a request ID and safe operation context.
Practice: Build A Conditional JSON Response
Implement ETag handling for a stable product representation.
Task
Write a function that:
- encodes a product under
data - hashes the encoded body into a quoted ETag
- returns
200, JSON body,Content-Type,ETag, and a private revalidation cache policy when no tag matches - returns
304with the same ETag and an empty body whenIf-None-Matchmatches
Call it once without a request tag, then again with the first response's ETag. Print both statuses and body lengths.
Show solution
The validator is based on the exact representation bytes sent to the client.
<?php
declare(strict_types=1);
function conditionalProductResponse(array $product, ?string $ifNoneMatch): array
{
$body = json_encode(['data' => $product], JSON_THROW_ON_ERROR);
$etag = '"' . hash('sha256', $body) . '"';
$headers = [
'Content-Type' => 'application/json',
'ETag' => $etag,
'Cache-Control' => 'private, max-age=0, must-revalidate',
];
if ($ifNoneMatch === $etag) {
return ['status' => 304, 'headers' => $headers, 'body' => ''];
}
return ['status' => 200, 'headers' => $headers, 'body' => $body];
}
$product = ['id' => 'prod_123', 'name' => 'Notebook'];
$first = conditionalProductResponse($product, null);
$second = conditionalProductResponse($product, $first['headers']['ETag']);
echo $first['status'] . ' ' . strlen($first['body']) . PHP_EOL;
echo $second['status'] . ' ' . strlen($second['body']) . PHP_EOL;
// Prints:
// 200 44
// 304 0
Private revalidation prevents a shared cache from serving user-specific data. Public cacheability requires a separate authorization and representation design.