Web Development

JSON Request Bodies in PHP - Validate the Media Type, Bytes, and Shape Separately

JSON Request Bodies in PHP - Validate the Media Type, Bytes, and Shape Separately

A PHP endpoint receives bytes, not trustworthy application data. Calling json_decode() is necessary when those bytes contain JSON, but a successful decode answers only one question: did the input match syntax that the decoder accepts? It does not prove that the client sent the media type the endpoint supports, that the body is reasonably sized, that the top-level value is an object, or that a field is meaningful for the operation.

A useful design is therefore not “decode, then hope.” It is a sequence of narrower decisions. Each rejected request should fail at the boundary it actually crossed. This makes the endpoint easier to reason about and gives clients a more stable contract.

One request crosses several boundaries

It helps to separate five layers that can otherwise collapse into one vague “invalid input” branch:

  1. Media type: did the client label the content as a format this endpoint supports?
  2. Bytes: can the server accept and read this amount of content?
  3. Syntax: are the bytes valid JSON in an encoding the decoder accepts?
  4. Shape: is the decoded value the object, fields, and PHP types required by this endpoint?
  5. Meaning: is the requested operation allowed in the current business and authorization state?

Passing one layer says little about the next. The JSON text true, for example, is syntactically valid. So are null, a string, and an array. RFC 8259 defines a JSON value broadly; an endpoint that expects an object has to impose that narrower rule itself.

Check the request's Content-Type first

Content-Type describes how the content is intended to be interpreted. It is not interchangeable with Accept, which expresses preferences for a response representation. If an endpoint contract accepts only JSON request bodies, a non-JSON request can be rejected with 415 Unsupported Media Type. Both RFC 9110's Content-Type definition and the OWASP REST Security Cheat Sheet support validating this boundary.

The media type can have parameters, so comparing the complete header string with application/json is unnecessarily brittle. Media type names are case-insensitive. The example below takes the value before the first semicolon, trims it, and compares a lowercase form. It deliberately accepts exactly application/json. Whether to accept vendor types such as application/example+json is an API contract decision, not a suffix check that every endpoint should silently add.

A missing Content-Type is treated as unsupported here because this particular operation requires a JSON body. That does not mean every bodyless HTTP request needs the header.

Put the earliest size limit outside PHP

HTTP defines 413 Content Too Large for content larger than the server is willing or able to process. The right number depends on the operation: a short settings object and a batch import should not inherit the same limit merely for convenience.

An application can check strlen($raw), but only after the body has reached PHP and has been read into a string. That check enforces the application contract; it is not a complete memory or denial-of-service boundary. A web server, reverse proxy, or request-handling layer should enforce an earlier limit appropriate to the deployment. The application check remains useful because it keeps the endpoint's own assumption explicit and protects it if upstream configuration changes.

Content-Length can help infrastructure frame or reject a request, but application validation should not treat that header alone as proof of the bytes ultimately received. Enforce limits where content is accepted, and avoid logging the entire rejected body.

Read raw content, then decode explicitly

For a native PHP endpoint, php://input provides a read-only stream for raw request-body data. The $_POST superglobal is not the raw JSON-body interface. Reading the stream makes the transition from HTTP content to an application string visible.

The decoder deserves explicit policy too. PHP documents that json_decode() accepts UTF-8 input, takes a maximum nesting depth, and supports JSON_THROW_ON_ERROR. Networked JSON is also required to use UTF-8 by RFC 8259. With the exception flag, a valid JSON null is no longer confused with the default error return of null. PHP throws JsonException for decode failures when that flag is enabled.

The example catches that exception and returns one restrained client-facing message. Internally, malformed syntax, invalid UTF-8, and excessive nesting can be logged as different diagnostic categories if useful, but exposing parser detail is rarely necessary for a small public API.

A compact PHP example

<?php

declare(strict_types=1);

function respondWithError(int $status, string $detail): never
{
    http_response_code($status);
    header('Content-Type: application/json');
    echo json_encode(['error' => $detail], JSON_THROW_ON_ERROR);
    exit;
}

$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
$mediaType = strtolower(trim(explode(';', $contentType, 2)[0]));

if ($mediaType !== 'application/json') {
    respondWithError(415, 'This endpoint accepts application/json.');
}

$raw = file_get_contents('php://input');
if ($raw === false) {
    throw new RuntimeException('Could not read the request body.');
}

$maxBytes = 64 * 1024; // Example policy, not a universal limit.
if (strlen($raw) > $maxBytes) {
    respondWithError(413, 'The request body is too large.');
}

if ($raw === '') {
    respondWithError(400, 'A JSON request body is required.');
}

try {
    $data = json_decode(
        $raw,
        associative: false,
        depth: 64,
        flags: JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING
    );
} catch (JsonException) {
    respondWithError(400, 'The request body is not valid JSON.');
}

if (!$data instanceof stdClass) {
    respondWithError(422, 'The top-level JSON value must be an object.');
}

$fields = get_object_vars($data);
if (!array_key_exists('title', $fields) || !is_string($fields['title'])) {
    respondWithError(422, 'The title field must be a string.');
}

$title = trim($fields['title']);
if ($title === '' || mb_strlen($title) > 160) {
    respondWithError(422, 'The title must contain 1 to 160 characters.');
}

// Authentication, authorization, persistence, and conflict checks belong here.
http_response_code(204);

The 64 KiB body limit and nesting depth of 64 are example policies, not universal safe values. They should be chosen from the endpoint's expected document and deployment constraints. The response helper is similarly minimal; an API that already uses Problem Details can preserve this processing sequence while returning its established error format.

Syntax and shape need different decisions

Decoding to an object preserves an important distinction at the top level. If the operation requires a JSON object, an array or scalar should not drift into field validation through PHP coercion. After the object check, get_object_vars() exposes its fields without pretending they have already become a trusted domain object.

The example distinguishes malformed JSON with 400 Bad Request from valid JSON that does not match the required application shape with 422 Unprocessable Content. RFC 9110 describes 422 as content whose syntax is correct but whose contained instructions cannot be processed. This is a coherent mapping, not the only possible API convention. The important part is to document one policy and apply it consistently.

Field validation should also distinguish “missing” from “present with the wrong type.” Using array_key_exists() makes that decision explicit. A string is then trimmed and checked against the operation's length rule. Escaping is intentionally absent: output encoding belongs at the eventual output context, while database values belong in parameterized queries. Mutating input into something that merely looks safer can hide a contract error.

Details a small example cannot solve

Duplicate object names

RFC 8259 says object member names should be unique and warns that receiver behavior differs when they are not. Ordinary json_decode() is not a duplicate-name validator. If duplicate detection matters for signatures, auditing, or a strict protocol, use a parsing approach that can detect it before members collapse into a PHP object.

Numbers and identifiers

The example uses JSON_BIGINT_AS_STRING to avoid converting sufficiently large integer tokens to an imprecise floating-point value. That flag is not a complete numeric policy. Money, measurements, ranges, and arithmetic still need domain-specific handling. Values such as account IDs are often better defined as JSON strings when they are identifiers rather than quantities.

Unknown fields

Whether to reject unknown members is a compatibility choice. Rejecting them catches misspellings early; ignoring them can make additive evolution easier. Either policy can be reasonable, but accidental inconsistency across endpoints is harder for clients than either documented choice.

Authorization and state

A perfectly shaped object can still request an operation the caller may not perform, refer to a resource that no longer exists, or conflict with current state. Parsing and validation do not replace authentication, authorization, concurrency controls, or database constraints. They merely produce a safer input to those later decisions.

A layered checklist

  • Define which request media types the endpoint accepts.
  • Apply an early body-size limit in the web or proxy layer and a matching application policy.
  • Read the raw body from php://input.
  • Decode with an explicit depth and unambiguous error handling.
  • Require the intended top-level JSON type.
  • Validate field presence, PHP type, range, length, and format without silent coercion.
  • Decide how to handle unknown fields, duplicate names, and large numbers.
  • Only then evaluate authorization, application state, and persistence.

The goal is not to turn a small endpoint into a ceremony. It is to avoid asking one function to answer five different questions. Once media type, bytes, syntax, shape, and meaning have separate boundaries, failures become easier to explain and changes become easier to review.

References