HTTP Clients And APIs

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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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:

  • Location identifies a newly created resource after 201 Created
  • Cache-Control states whether and how a response may be cached
  • ETag or Last-Modified supports conditional requests
  • Vary tells shared caches which request headers affect representation
  • Retry-After can guide clients after 429 or 503
  • a request or correlation ID helps support trace a failure
PHP example
<?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_FRACTION keeps 12.0 visibly distinct from 12 when that distinction matters
  • JSON_UNESCAPED_SLASHES improves readability of URLs but does not change their value
  • JSON_UNESCAPED_UNICODE keeps readable Unicode instead of escape sequences
  • JSON_INVALID_UTF8_SUBSTITUTE replaces 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 example
<?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 example
<?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 example
<?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, or dd() 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 example
<?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 204 and 304
  • 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, and body.
  • Success responses must use a data key.
  • Validation errors must use an errors key.
  • 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 example
<?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 200 for successful encoding
  • catch JsonException
  • return status 500 and 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 example
<?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 304 with the same ETag and an empty body when If-None-Match matches

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 example
<?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.