Skip to content
advanced Phase 16 · Capstone: Production PHP Application

Implementing the REST Endpoints

Build CRUD routes with auth, validation, and pagination.

2h
0 problems
Topic Progress 0%

Routing to a Handler and Returning One JSON Envelope

Every request in this app enters through one file. public/index.php is the front controller: parse the request, find the handler, and translate its result into one envelope shape with the status code your plan already decided. Handlers never call echo, never set headers, never touch http_response_code — they return data or throw, and the envelope layer owns the wire format.

<?php
// public/index.php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';

use App\Http\{Request, Response, Router};

$request = Request::fromGlobals();     // method, path, parsed JSON body, headers

$routes = [
    ['GET',    '#^/products$#',                        'ProductController@index',   false],
    ['GET',    '#^/products/(?P<sku>[A-Za-z0-9-]+)$#', 'ProductController@show',    false],
    ['POST',   '#^/products$#',                        'ProductController@store',   true],
    ['DELETE', '#^/products/(?P<sku>[A-Za-z0-9-]+)$#', 'ProductController@destroy', true],
];

try {
    $router  = new Router($routes);
    [$handler, $params, $needsAuth] = $router->match($request);   // throws RouteNotFound
    $request = Auth::authenticate($request);                      // throws Unauthorized
    if ($needsAuth) { Auth::requireRole($request, 'admin'); }     // throws Forbidden

    $response = $router->dispatch($handler, $params, $request);
} catch (Throwable $e) {
    $response = ExceptionMapper::toResponse($e);   // one place converts errors to envelopes
}

$response->send();

That catch block is the architectural point: all failures funnel through a single mapper, so RouteNotFound → 404, Unauthorized → 401, Forbidden → 403, ValidationFailed → 422, Conflict → 409, and any unhandled Throwable → 500 with a logged exception id but a body that leaks nothing. Twenty handlers each catching their own exceptions guarantees twenty inconsistent error formats by month two.

The two envelope shapes:

{ "data": { "sku": "A1", "name": "Widget", "unit_price": "19.99" },
  "meta": { "limit": 100, "page": 1, "total": 121 } }

{ "error": { "code": "validation_failed",
             "message": "The request body has invalid fields.",
             "fields": { "unit_price": "must be a number with up to 2 decimals" } } }

data for success, error for failure, meta only where a list is involved — three keys, no per-endpoint improvisation. The payoff is mechanical: one client-side parser, one assertSuccess() helper in the test suite, and API docs that can be generated instead of maintained.

Two implementation details inside Response::json() decide whether the envelope survives contact with bad data. Encode with flags, and let failure be loud:

$body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);

JSON_THROW_ON_ERROR turns "the encoder returned false and you sent an empty 200" into an exception your ExceptionMapper converts to a logged 500 — the invalid-UTF-8 failure mode you met in the string-functions material is caught here, in the harness, rather than by a confused client. Content-Type: application/json goes on every response including errors, because a client parsing an HTML error page is how fallback bugs are born.

Now a handler doing real work — create, with validation before any SQL:

public function store(Request $r): Response
{
    $v = Validator::make($r->json(), [
        'sku'        => ['required', 'regex:/^[A-Za-z0-9-]{1,64}$/', 'unique:products,sku'],
        'name'       => ['required', 'max:255'],
        'unit_price' => ['required', 'numeric', 'min:0', 'decimal:0,2'],
    ]);
    if ($v->fails()) {
        throw ValidationFailed::with($v->errors());        // → 422, error.fields
    }

    $row = $this->products->insert($r->json());           // PDO prepared statement
    return Response::json($row, 201)                      // 201, never 200
        ->withHeader('Location', '/products/' . $row['sku']);
}

Three status-code rules do most of the interviewing for you:

Situation Code Why not the obvious alternative
body is valid JSON, rules fail 422 400 is for JSON the parser could not read
resource created 201 + Location 200 does not tell the client where the new thing lives
delete succeeded 204 empty body 200 with {"deleted":true} re-implements the envelope for one route
row not found 404 returning 200 with data: null breaks every caller's check

Everything above is deliberately testable — each line maps to an acceptance behavior (AC-01…AC-07) fixed before any handler existed, against tables you built in Modeling the Database Schema. The Auth::authenticate and requireRole calls are the next chapter's subject, because deciding when they run in that pipeline — and which route flags skip them — is where most APIs get 401 and 403 backwards.

Bearer-Token Middleware: Why 401 and 403 Are Different Answers

The pipeline in the previous chapter calls two things that deserve their own chapter, because the split between them is the whole answer to a question interviewers ask almost verbatim: 401 or 403? Authentication says who are you; authorization says what may this identity do. Two steps, two failure types, one header that tells the client what to do next.

Issuing a token is where it starts:

// POST /auth/token  { "email": "...", "password": "..." }
$user = $this->users->findByEmail($email);
if (!$user || !password_verify($password, $user['password_hash'])) {
    throw new Unauthorized('Invalid credentials');     // still plain 401, no hint which field
}
$plain = bin2hex(random_bytes(32));                   // 64 hex chars, unpredictable
$this->tokens->insert([
    'token_hash' => hash('sha256', $plain),           // store the HASH, never the token
    'user_id'    => $user['id'],
    'expires_at' => (new DateTime('+1 hour'))->format('Y-m-d H:i:s'),
]);
return Response::json(['data' => ['token' => $plain, 'expires_in' => 3600]], 201);

Storing sha256(token) is the same reasoning as storing password_hash: a database dump must not yield working credentials. The plaintext token exists once, in the response; everything after that is a hash lookup.

Checking it lives in middleware, before validation and before the handler:

final class Auth
{
    public static function authenticate(Request $r): Request
    {
        $header = $r->header('Authorization') ?? '';
        if (!preg_match('/^Bearer ([A-Za-z0-9_-]{64})$/', $header, $m)) {
            throw new Unauthorized('Missing or malformed Authorization header');
        }
        $token = self::lookupByHash(hash('sha256', $m[1]));   // indexed lookup, no secret in SQL
        if ($token === null || $token['expires_at'] < date('Y-m-d H:i:s')) {
            throw new Unauthorized('Token expired or unknown');
        }
        return $r->withUser($token['user']);
    }

    public static function requireRole(Request $r, string $role): void
    {
        if ($r->user()['role'] !== $role) {
            throw new Forbidden("Role '$role' required");      // authenticated — just not allowed
        }
    }
}

Note what is absent: no early return null paths that skip the handler, no === comparison against attacker-supplied material, and no user-facing distinction between "unknown token" and "expired token" beyond what helps an honest client recover. If you ever compare a secret that arrived from the client against a stored value directly, hash_equals($stored, $given) gives you the constant-time comparison; the indexed hash-lookup shape above sidesteps the question because the secret never participates in a branchy comparison.

The mapping clients depend on:

Condition Code Header / body
no Authorization header 401 WWW-Authenticate: Bearer realm="api"
token unknown or expired 401 same — client should fetch a new token
valid token, role not required by route 403 error.code = "forbidden"
valid token, wrong role for this route 403 same
valid token, resource owned by someone else 403 same — never 404 unless hiding existence is a stated goal

Two consequences make the distinction practical rather than pedantic. If permission failures returned 401, every mobile client's interceptor would treat them as "token expired," refresh, retry, get 401 again — an infinite refresh loop on a route the user was never allowed to touch. And returning 404 to "hide" forbidden resources makes the API inconsistent: the same row answers 200 to an admin and 404 to a worker, which is a leak anyway (timing and error differences remain) and breaks monitoring that counts 404s.

Middleware order is the last decision: authenticate → authorize → validate body → handler. Reverse any pair and you get either unauthenticated users reaching validation code (wasted work, attacker probing) or payloads parsed before you know who is asking. The route table's boolean column from the previous chapter is what lets GET /products skip the whole thing while every write route runs it — public reads, authenticated writes, exactly as the endpoint plan specified.

The suite now has something meaty to assert: 401 with no header, 403 with a worker token, 201 with an admin token — three cases per write endpoint, and the shape of those tests is the next topic. First, the reads need an answer for what happens when the inventory is not 121 rows but 200,000.

Pagination That Keeps a 200,000-Row Inventory Responsive

GET /products returning all 200,000 rows is not a scaling problem yet — it is a single-request outage: PHP buffers the result, the JSON encoder doubles it, the client chokes, and one ordinary call takes the server down. Pagination is a denial-of-service control first and a UX feature second, which is why the cap is server policy rather than a client preference.

$limit  = min(max((int)($request->query('limit') ?? 20), 1), 100);   // default 20, hard cap 100
$page   = max((int)($request->query('page') ?? 1), 1);
$offset = ($page - 1) * $limit;

$stmt = $this->pdo->prepare(
    'SELECT sku, name, unit_price, updated_at
       FROM products
      WHERE archived_at IS NULL
      ORDER BY id DESC
      LIMIT :limit OFFSET :offset'
);
$stmt->bindValue('limit', $limit, PDO::PARAM_INT);
$stmt->bindValue('offset', $offset, PDO::PARAM_INT);
$stmt->execute();

return Response::json([
    'data' => $stmt->fetchAll(PDO::FETCH_ASSOC),
    'meta' => [
        'limit' => $limit,          // the EFFECTIVE limit, not the requested one
        'page'  => $page,
        'total' => $this->products->countFiltered($request->query()),
    ],
]);

Binding limit/offset as integers matters: string-typed binds are the classic hole that lets limit=1; DROP style payloads through in looser drivers. And meta.limit echoes the capped value — a client that asked for 500 and silently received 100 must be able to see 100, or its paging logic corrupts itself.

Underneath, two costs decide whether this holds up:

Query Cost Failure mode
LIMIT 100 OFFSET 199900 engine walks and discards 199,900 rows page 2000 takes seconds, not milliseconds
SELECT COUNT(*) ... on every list full index or table scan total becomes the hot-path query

OFFSET grows linearly with page depth — pages 1–10 are fine, page 5,000 is not. When deep access matters, switch to keyset (cursor) pagination, which seeks instead of skipping:

-- first page
SELECT ... FROM products ORDER BY id DESC LIMIT 100;
-- every later page: pass the last id you saw as an opaque cursor
SELECT ... FROM products WHERE id < :cursor ORDER BY id DESC LIMIT 100;

Cost per page becomes constant regardless of depth, because the index on id seeks straight to the stopping point. The trade-off is real: you lose random page jumps — no ?page=500 — so keyset fits infinite-scroll feeds and exports, while limit/offset fits numbered pages where users jump (and where those pages stay shallow). Returning the cursor as an opaque base64 token rather than a raw number also lets you change the scheme later without breaking clients.

Then total: COUNT(*) over 200k filtered rows on every request is usually the most expensive part of the response. Practical answers, in order of preference: drop total where clients do not strictly need it ("1,200+ results" is often enough), cache it with a TTL and invalidate on write, or compute it asynchronously. Depth is capped either way — page beyond the last page returns data: [] with 200, never 404, because an empty page is a legitimate result, not a missing resource.

One more server-protection rule rides along: list endpoints select only the columns the client renders (sku, name, unit_price, updated_at — not SELECT *), because the products table will eventually carry columns nobody asked to list. Column-level cost is invisible until a TEXT description column turns every list response into a megabyte.

None of this behavior is self-evident to a reviewer — which is precisely why the acceptance list pinned meta.limit = 100, meta.total = 121, and page=99 → data: [] before this code existed. In Covering the API with PHPUnit those lines become assertions, and a refactor that silently drops the cap fails the suite instead of passing review.

Quiz

1. A client sends a syntactically valid JSON body with unit_price: "free" to POST /products. What does a correctly implemented endpoint return?

Question 1 options

2. An authenticated worker sends DELETE /products/A1. The endpoint requires role admin. Which response is correct and why?

Question 2 options

3. GET /products?limit=500 arrives at an endpoint whose maximum page size is 100. What should happen?

Question 3 options

4. offset pagination degrades as the inventory grows: page=5000 with limit=100 runs slowly. What is the mechanism and the standard fix?

Question 4 options

Flashcards

Question

The success and error JSON envelope shapes

Answer

Success: {"data": ..., "meta": {"limit":100,"total":121,"page":2}} with 200/201/204. Error: {"error": {"code": "validation_failed", "message": "...", "fields": {"unit_price": "must be numeric"}}} with 401/403/404/409/422. One shape for every response means one client-side parser and one test helper.

Question

401 vs 403 — exact distinction and the headers involved

Answer

401 Unauthorized = credentials missing or invalid; send WWW-Authenticate: Bearer so the client knows the scheme. 403 Forbidden = authentication succeeded, the identity is not permitted (role/scope/ownership). Re-authenticating must never be the fix for 403, and returning 401 for permission failures traps clients in token refresh loops.

Question

How the auth middleware authenticates a request, end to end

Answer

Read Authorization: Bearer <token>; reject missing/malformed with 401; look up the token by its SHA-256 hash (store hashes, not plaintext tokens) in constant time with hash_equals; attach the user row to the request; then a separate authorization step checks role (403). Middleware order matters: authenticate before validate, validate before handler.

Question

limit/offset pagination math and its cap

Answer

LIMIT $limit OFFSET ($page-1)*$limit with $limit capped server-side (default 20, max 100) and meta reporting the effective values. Deep pages are O(offset) because skipped rows are still walked — that is when keyset pagination (WHERE id > :cursor ORDER BY id LIMIT n) takes over, at the cost of losing random page jumps.

Question

What a create request returns and where validation errors live

Answer

POST returns 201 with Location: /products/{sku} and the created body under data — never 200 for a new resource. Rule violations return 422 with per-field messages under error.fields; unparsable JSON returns 400. The status code comes from the plan's endpoint table, so the handler's job is only to map its internal result onto it.

Revision Notes

Key Takeaways

  • 1. One envelope shape for success ({data, meta}) and one for failure ({error{code,message,fields}}) makes clients, tests, and docs trivially consistent
  • 2. Status codes come from the planning table: 201+Location on create, 204 on delete, 400 unparsable JSON, 401 unauthenticated, 403 forbidden, 404 unknown, 409 conflict, 422 rule violation
  • 3. Authentication (who are you) and authorization (what may you do) are separate middleware steps — that separation is what produces 401 vs 403 correctly
  • 4. Token comparison is hash-lookups plus hash_equals; never store raw tokens, never compare with === on a database primary key lookup path
  • 5. Pagination is server policy: cap the limit, report meta, and switch to keyset pagination when offset depth grows

Interview Tips

  • • "Walk me through a request hitting your API" — front controller → middleware (auth → validate → handler) → response object with envelope; naming the layer order shows you have built one
  • • "401 or 403?" — give the crisp rule (401 = authenticate, 403 = authenticated but denied) plus the WWW-Authenticate header; then mention ownership checks as a third case that is still 403
  • • "How do you stop one client from flooding you?" — capped page size, prepared statements, rate limiting at nginx limit_req plus a token bucket per user in the app; layered answers score higher than one
  • • "limit/offset breaks at scale — fix it" — keyset/cursor pagination on a monotonic column, note the lost ability to jump to arbitrary pages, and mention keeping meta.total off the hot path (cached or approximate)

Cheat Sheet

Implementing the REST Endpoints

Envelope (one shape everywhere):

{ "data": {...}, "meta": { "limit": 100, "page": 2, "total": 121 } }
{ "error": { "code": "validation_failed", "message": "...", "fields": { "unit_price": "..." } } }

Status codes (decided in planning, enforced by one ExceptionMapper):

Code Meaning Never
200 / 201+Location / 204 read / created / deleted-no-body 200 for a create
400 JSON the parser cannot read used for rule violations
401 + WWW-Authenticate: Bearer no or invalid credentials returned for permission failures
403 authenticated, not permitted (role/ownership) 404 to "hide" rows
404 route or row not found 200 with data: null
409 FK conflict / concurrent-edit conflict —
422 valid JSON, rules failed → error.fields 500 from a PDO type error
500 unhandled, logged id, body leaks nothing stack traces to clients

Pipeline order: front controller → authenticate → authorize → validate → handler → ExceptionMapper → Response::send(). Handlers return data or throw; they never echo or set headers.

Auth: POST /auth/token verifies with password_verify, issues bin2hex(random_bytes(32)), stores sha256(token) + expires_at. Request path: Authorization: Bearer <64 hex> → indexed hash lookup → attach user → role check. hash_equals for any direct secret comparison. Route table flags which paths are public (reads) vs protected (writes).

$limit = min(max((int)($q['limit'] ?? 20), 1), 100);   // cap is server policy
$stmt->bindValue('limit', $limit, PDO::PARAM_INT);      // typed binds
OFFSET ($page-1)*$limit                                  -- walks skipped rows: deep pages degrade
WHERE id < :cursor ORDER BY id DESC LIMIT 100           -- keyset: constant cost, no page jumps

Pagination rules: echo the effective limit in meta, page past the end → data: [] + 200, select only needed columns, treat COUNT(*) as optional/cacheable. Pagination before anything else is a denial-of-service control.

Continue Learning