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?
2. An authenticated worker sends DELETE /products/A1. The endpoint requires role admin. Which response is correct and why?
3. GET /products?limit=500 arrives at an endpoint whose maximum page size is 100. What should happen?
4. offset pagination degrades as the inventory grows: page=5000 with limit=100 runs slowly. What is the mechanism and the standard fix?
Flashcards
Question
The success and error JSON envelope shapes
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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.