Kodokon kodokon.com

Building a JSON API in PHP

Build a rigorous JSON API by mastering headers, reading the raw body and the fine-grained semantics of status codes.

9 min · 3 questions

Open this lesson in Kodokon

A JSON API is a contract: every response announces its type (Content-Type: application/json), carries the right status code and a stable structure. PHP sends none of this by default - it declares text/html. First building block: a single response helper, typed never (PHP 8.1+). This return type guarantees, to the engine as well as to static analysis tools, that the function never hands control back: any code written after a call is provably unreachable.

PHP
<?php
declare(strict_types=1);

function jsonResponse(
    mixed $data,
    int $status = 200
): never {
    http_response_code($status);
    header('Content-Type: application/json');
    echo json_encode(
        $data,
        JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
    );
    exit;
}

jsonResponse(['status' => 'ok']);
A single exit point: status, header and body always consistent.

A second subtlety, often discovered too late: $_POST stays empty in the face of a JSON body. PHP only fills it for application/x-www-form-urlencoded and multipart/form-data. The raw body is read from the php://input stream. Decode it with JSON_THROW_ON_ERROR: without that flag, json_decode silently returns null on error - indistinguishable from a body that literally contains null. And thanks to the never type, the switch below needs no break at all.

PHP
<?php
declare(strict_types=1);

switch ($_SERVER['REQUEST_METHOD']) {
    case 'GET':
        jsonResponse(['items' => []]);
    case 'POST':
        $raw = (string) file_get_contents(
            'php://input'
        );
        try {
            $payload = json_decode(
                $raw,
                true,
                512,
                JSON_THROW_ON_ERROR
            );
        } catch (JsonException) {
            jsonResponse(
                ['error' => 'Malformed JSON'],
                400
            );
        }
        header('Location: /items/42');
        jsonResponse(['created' => $payload], 201);
    default:
        header('Allow: GET, POST');
        jsonResponse(
            ['error' => 'Method not allowed'],
            405
        );
}
Assumes jsonResponse defined above; the catch without a variable is PHP 8.

Choose the codes precisely, because your clients will program against them, not against your messages: 200 successful read, 201 creation (accompanied by a Location header pointing to the resource), 204 success with no body, 400 malformed JSON, 404 missing resource, 405 unhandled method (with an Allow header listing the permitted ones), and 422 for JSON that is syntactically valid but semantically incorrect. Check it all with curl -i, which displays the received headers.

BASH
curl -i -X POST http://localhost:8000/items \
  -H 'Content-Type: application/json' \
  -d '{"name":"Widget"}'
The -i option shows the status and headers: the only real test of the contract.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Your API receives a POST with a JSON body: why is $_POST empty?
    • PHP only populates $_POST for form-urlencoded and multipart bodies; a JSON body is read from php://input
    • The JSON exceeds the default post_max_size limit
    • You first have to call json_decode on $_POST to fill it
  2. Which status code should you return after a successful creation via POST?
    • 200
    • 201
    • 204
    • 302
  3. What does the never return type guarantee?
    • The function always returns null
    • The function never hands control back: it ends with exit or an exception, and the engine verifies this
    • The function cannot be overridden by a child class