First PHP Projects

JSON API

Expose the existing product repository through JSON. The API adds a representation boundary; it should not create a second SQL path. Learn the response, body, validation, authorization, and routing decisions separately.

GET  /api.php/products
GET  /api.php/products/{id}
POST /api.php/products

Commit To One Response Shape

Encode before sending headers so an encoding failure can still become controlled JSON:

PHP example
<?php

declare(strict_types=1);

function jsonResponse(array $payload, int $status = 200, array $headers = []): never
{
    try {
        $body = json_encode(
            $payload,
            JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES,
        ) . PHP_EOL;
    } catch (JsonException) {
        $status = 500;
        $body = "{\"error\":{\"code\":\"encoding_failed\",\"message\":\"Response encoding failed.\"}}\n";
    }

    http_response_code($status);
    header('Content-Type: application/json; charset=utf-8');
    foreach ($headers as $name => $value) {
        header($name . ': ' . $value);
    }
    echo $body;
    exit;
}

A small errorResponse() helper wraps a stable error code and message. Field errors are added only for validation failures; SQL and exception details never enter the payload.

Reject The Wrong Media Type Early

Before reading the body, require the contract and reject a declared oversized request:

PHP example
// Partial: start of readJsonObject().
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
$mediaType = strtolower(trim(explode(';', $contentType, 2)[0]));
if ($mediaType !== 'application/json') {
    errorResponse(415, 'unsupported_media_type', 'Use Content-Type: application/json.');
}

$length = $_SERVER['CONTENT_LENGTH'] ?? null;
if (is_string($length) && ctype_digit($length) && (int) $length > 32_768) {
    errorResponse(413, 'body_too_large', 'Request body is too large.');
}

Content-Length can be missing, so it is only an early rejection, not the actual bound.

Bound The Stream Itself

Read one byte beyond the limit. That detects an oversized body without buffering the rest:

PHP example
// Partial: bounded php://input read.
$stream = fopen('php://input', 'rb');
$raw = $stream === false ? false : stream_get_contents($stream, 32_769);
if (is_resource($stream)) {
    fclose($stream);
}
if (!is_string($raw)) {
    errorResponse(400, 'body_unavailable', 'Request body could not be read.');
}
if (strlen($raw) > 32_768) {
    errorResponse(413, 'body_too_large', 'Request body is too large.');
}

Now decoding work is bounded regardless of transport headers.

Require A JSON Object

Valid JSON is not automatically a product request. Null, booleans, numbers, strings, and lists all decode successfully:

PHP example
// Partial: final readJsonObject() step.
try {
    $decoded = json_decode($raw, false, 64, JSON_THROW_ON_ERROR);
} catch (JsonException) {
    errorResponse(400, 'invalid_json', 'Request body is not valid JSON.');
}

if (!$decoded instanceof stdClass) {
    errorResponse(400, 'object_required', 'Request body must be a JSON object.');
}

return get_object_vars($decoded);

Decoding to stdClass first preserves the distinction between an empty object and an empty list.

Validate JSON Types Strictly

JSON numbers and quoted numbers are different types. Keep them different:

PHP example
// Partial: selected validateProductPayload() rules.
if (!is_string($payload['name'] ?? null)
    || trim($payload['name']) === ''
    || strlen(trim($payload['name'])) > 100) {
    $errors['name'] = 'Enter a name up to 100 bytes.';
}
if (!is_int($payload['price_cents'] ?? null)
    || $payload['price_cents'] < 0
    || $payload['price_cents'] > 99_999_999) {
    $errors['price_cents'] = 'Use an integer from 0 to 99999999.';
}
if (!is_string($payload['status'] ?? null)
    || !in_array($payload['status'], ['draft', 'published'], true)) {
    $errors['status'] = 'Choose draft or published.';
}

Reject unknown keys as well. A string price, float, array, or huge number must not be silently coerced before PDO.

Normalize The Response Boundary

Different PDO drivers can return numeric columns differently. Make API scalar types explicit:

PHP example
<?php

declare(strict_types=1);

function productResource(array $row): array
{
    return [
        'id' => (int) $row['id'],
        'name' => (string) $row['name'],
        'price_cents' => (int) $row['price_cents'],
        'status' => (string) $row['status'],
    ];
}

This controls representation only; it does not validate request input or query PDO.

Build Read Routes First

The list route validates bounded pagination and reuses ProductRepository::page():

PHP example
// Partial: GET /api.php/products.
$page = filter_var($_GET['page'] ?? 1, FILTER_VALIDATE_INT, [
    'options' => ['min_range' => 1, 'max_range' => 1_000_000],
]);
$limit = filter_var($_GET['limit'] ?? 20, FILTER_VALIDATE_INT, [
    'options' => ['min_range' => 1, 'max_range' => 100],
]);
if ($page === false || $limit === false) {
    errorResponse(400, 'invalid_pagination', 'Page and limit are out of range.');
}

$rows = $repository->page($limit, ($page - 1) * $limit);
jsonResponse(['data' => array_map('productResource', $rows),
    'meta' => ['page' => $page, 'limit' => $limit]]);

The detail route matches only a positive decimal ID, returns a typed resource when found, and stable 404 product_not_found JSON when absent.

Protect The Write Before Parsing It

This project uses the existing session cookie, so creation also requires CSRF:

PHP example
// Partial: start of POST /api.php/products.
$currentUser = currentUser($pdo, $_SESSION['user_id'] ?? null);
if ($currentUser === null) {
    errorResponse(401, 'authentication_required', 'Authentication required.');
}
if (($currentUser['role'] ?? null) !== 'admin') {
    errorResponse(403, 'forbidden', 'Administrator access required.');
}

$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
    errorResponse(403, 'invalid_csrf', 'Request token is invalid.');
}

Only then reserve write-rate capacity, read the bounded body, validate it, and insert:

PHP example
// Partial: accepted create path.
$payload = readJsonObject();
$errors = validateProductPayload($payload);
if ($errors !== []) {
    errorResponse(422, 'validation_failed', 'Product data is invalid.', $errors);
}

$id = $repository->insert(
    trim($payload['name']),
    $payload['price_cents'],
    $payload['status'],
);

$product = $repository->find($id);
if ($product === null) {
    throw new RuntimeException('Created product could not be reloaded.');
}

Return the typed product with 201 and Location. Unexpected exceptions are caught only by the outer API boundary, logged without sensitive request data, and returned as generic JSON 500.

Verify As A Client

Test list, found, missing, bad pagination, wrong method with Allow, wrong media type, oversized body, malformed JSON, scalar/list JSON, unknown fields, string price, anonymous/non-admin create, bad CSRF, successful creation, invalid UTF-8 response data, and forced repository failure.

Practice

Practice: Build A Product JSON API

Implement the complete product API front controller from the lesson using the existing repository and authenticated session.

Requirements

  • Expose bounded list, find, and create routes through public/api.php.
  • Return only JSON with a stable data or error envelope.
  • Normalize PDO values into explicit JSON scalar types.
  • Validate page and limit as bounded positive integers.
  • Return 404 for missing products and routes, and 405 with Allow for unsupported collection methods.
  • Require application/json and read at most 32 KiB for create requests.
  • Distinguish malformed JSON, non-object JSON, unknown fields, and invalid product fields.
  • Require current administrator authorization and session CSRF for writes.
  • Return the created resource with 201 and Location.
  • Prevent invalid UTF-8 or encoding failures from leaking PHP errors.
  • Catch unexpected failures and return a generic JSON 500 response.
  • Do not expose exception messages, SQL, paths, or stack traces.
  • Identify where a shared write-rate limiter runs before expensive work.

Record complete curl requests, response status, relevant headers, and JSON body for every normal and rejected route listed in the lesson.

Show solution

Use jsonResponse(), errorResponse(), readJsonObject(), validateProductPayload(), and productResource() from the lesson. The front controller must bootstrap PDO, the repository, and the authenticated session before entering the route try block.

The create route follows this exact order:

  1. Match both path and POST method.
  2. Load the current session user and require the current admin role.
  3. Verify X-CSRF-Token with hash_equals().
  4. Reserve any configured write-rate-limit capacity.
  5. Enforce media type and bounded body reading.
  6. Decode a JSON object and reject unknown or incorrectly typed fields.
  7. Insert through ProductRepository and reload the stored row.
  8. Return the typed resource with 201 and Location.

A successful request using a previously established login session looks like:

curl -i \
  -b cookies.txt \
  -H 'Content-Type: application/json' \
  -H 'X-CSRF-Token: replace-with-session-token' \
  --data '{"name":"Notebook","price_cents":1299,"status":"draft"}' \
  http://localhost:8000/api.php/products

Expected response shape:

{"data":{"id":1,"name":"Notebook","price_cents":1299,"status":"draft"}}

The status is 201 Created, and Location identifies /api.php/products/1. Repeat the request with price_cents as "1299", an array, a float, and an oversized integer; every case must return 422 without inserting. Submit null, [], and a JSON string; each must return 400 object_required. Force a PDO exception and confirm the response remains generic JSON with status 500.