Skip to content
intermediate Phase 11 · HTTP and REST APIs

Routing Requests in a Front Controller

Map URL patterns and methods to handlers.

50m
0 problems
Topic Progress 0%

Sending Every Request to index.php and Recovering the Path with PATH_INFO

Ten endpoint files in public/ means ten entry points, each re-including the bootstrap, each with a fresh chance to forget the JSON header or the error handler. A front controller inverts that: the web server forwards every non-file request to one index.php, and that script decides what the URL meant.

Apache needs two rules in .htaccess:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f     # let real files (css, images) through
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA]           # everything else → front controller

nginx has no .htaccess; the equivalent lives in the server block:

location / {
    try_files $uri $uri/ /index.php?$query_string;
}

Why bother when public/articles.php worked fine? The costs of one-file-per-endpoint accumulate quietly: every file must require the bootstrap itself, and one miss means a second autoloader or a fatal error outside your handler; CORS headers, auth, and request-id logging get copied (or forgotten) per file; URLs expose physical filenames like /articles.php?id=42, which you cannot later change without breaking every bookmark; and the attack surface is N scripts that each accept input directly. The front controller collapses all four into a single file whose first act is installing the error handler — the difference between a 404 that renders your JSON error body and one that renders PHP's white screen.

Now /articles, /articles/42, and /v1/articles all execute index.php — but the path still has to reach your code. Four superglobal entries carry it, and they are not interchangeable:

Key Example Character
$_SERVER['PATH_INFO'] /articles/42 Path appended after the script name
$_SERVER['SCRIPT_NAME'] /index.php The script the request landed on
$_SERVER['REQUEST_URI'] /articles/42?expand=author Raw path + query, always present
$_SERVER['QUERY_STRING'] expand=author Query only

With Apache's AcceptPathInfo (on by default), a request to /index.php/articles/42 sets PATH_INFO=/articles/42: already split, already decoded, ready to match. nginx typically leaves PATH_INFO unset, which is why portable code falls back to REQUEST_URI:

$uri  = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$base = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/');    // '' or '/app'
$path = ($base !== '' && str_starts_with($uri, $base))
    ? substr($uri, strlen($base))
    : $uri;
$path = '/' . trim($path, '/');                          // canonical: no trailing slash

The [QSA] on the Apache rule — query string append — is why ?expand=author survives the rewrite, and nginx's /index.php?$query_string does the same job. When a rewrite silently drops the query, every filter parameter turns empty while routing keeps matching: nothing errors, results just get wrong, which is one of the nastiest regressions to spot.

parse_url(..., PHP_URL_PATH) matters because REQUEST_URI may carry an encoded query, encoded slashes, and ?page=2 noise — matching against the raw string is how routes silently fail the moment a client adds a query parameter.

A second question decides your file layout: where does bootstrap end and routing begin? Keep index.php to four steps — start the session, load the autoloader, register error handlers, then require the route table — because everything before routing (CORS headers, auth, request-id logging, exception handling) must run for every request regardless of route, while everything after is pure dispatch. That ordering is the same discipline you learned for responses in Reading Requests with Superglobals: decide first, emit last.

The front controller also gives you one choke point for cross-cutting answers. An OPTIONS preflight, a 405 for an unsupported verb, a health-check path for the load balancer — each gets handled once instead of copied into every file. And when a request reaches your router with a method attached, the question becomes mapping method-plus-path onto code. The response bodies are already defined by Building JSON Endpoints; the next piece decides which body a given request earns.

GET Reads, POST Creates: The Method Table and the 405 Fallback

REST's first trick is that the URL names what and the method names what to do. Once a team agrees on the table, every endpoint becomes predictable without a design meeting:

Method Operation Idempotent? Success status
GET Read collection or item Yes 200
POST Create a new resource No 201 + Location
PUT Replace a resource wholesale Yes 200 or 204
PATCH Partial update Only if defined that way 200
DELETE Remove a resource Yes 204
OPTIONS Allowed methods / CORS preflight Yes 200 + Allow

"Idempotent" earns its column: a client that times out and retries PUT /articles/42 arrives at the same final state, so proxies, SDKs, and retry loops may replay it safely. POST cannot be replayed blindly — retry a timed-out POST and you may create two orders — which is why duplicate protection (Idempotency-Key) is a POST-only concern, and why GET must never change state: caches will perform it for you.

The URL side supplies the nouns, and bad nouns are the most common review comment in a REST codebase:

GET    /articles            ✗  /getArticles         (verb in the path)
GET    /articles/42         ✗  /article?id=42       (resource id in the query)
POST   /articles/42/publish ✗  /publishArticle/42   (verb + id swapped)
GET    /articles/42/comments       (nested one level — fine)

Plural nouns, at most one level of nesting, no verbs: GET /articles lists, POST /articles creates, GET /articles/42 reads one. The verb lives in the method, so the same path under a different method means a different operation — which makes the mismatch case precisely definable. /articles/42 exists, but this server does not implement PATCH on it:

$allowed = ['GET', 'PUT', 'DELETE'];
if (!in_array($method, $allowed, true)) {
    header('Allow: ' . implode(', ', $allowed));   // required by RFC 9110
    http_response_code(405);
    echo json_encode(['error' => [
        'code'    => 'method_not_allowed',
        'message' => 'This route accepts: ' . implode(', ', $allowed),
    ]]);
    exit;
}

The Allow header is the point of the 405. Without it the client knows only "no"; with it an SDK can correct itself and a human sees the contract in a single curl. Returning 404 instead is the deeper mistake — it tells the client the resource does not exist when the resource is right there, and the team spends a sprint debugging a phantom data problem. Keeping the two apart ("your URL is wrong" versus "your verb is wrong") eliminates a whole class of tickets.

OPTIONS extends the same idea: answer the preflight with Allow plus, under CORS, Access-Control-Allow-Methods and Access-Control-Allow-Headers, rather than running a handler. Cheap to implement at the front-controller level, and it stops every browser from guessing what your API supports.

Two smaller rules complete the table. HEAD should behave like GET with the body stripped — useful for existence checks and cache validation — and GET responses should carry validators (ETag, Last-Modified) if clients will cache them. Both are decisions you make once in the dispatcher: what each method is allowed to do on a known path.

PUT and PATCH are also not synonyms, and conflating them is how data disappears. PUT /articles/42 with {"title":"x"} replaces the resource — fields you omitted are cleared — while PATCH modifies only what you sent. Send a partial document to a PUT endpoint and watch the missing columns empty out; that bug has a known shape and the verb table above prevents it. Decide per resource whether PUT means "complete document required" or "absent fields are ignored", write it in the docs, and answer with 200 (echoing the result) or 204 (saying nothing) consistently. The chapter that follows extracts /articles/42 into real arguments and routes them to exactly one handler.

Extracting /articles/42 into a Handler and Dispatching with match

The method table exists; now the path has to become arguments, and the arguments have to reach exactly one handler. This is where a hand-rolled router either stays safe or becomes a security bug.

Anchor every pattern. An unanchored #/articles/(\d+)# also matches /not-articles/42, /api/v1/articles/42, and any URL a client cares to append — routes you never intended to expose, bypassing whatever sits at the front of your path. Anchors turn the pattern into a contract:

$path = '/articles/42';
if (preg_match('#^/articles/(\d+)$#', $path, $m)) {
    $id = (int) $m[1];        // digits only by construction, then cast anyway
    $article = $repo->find($id);
}

The \d+ class plus the (int) cast means the value cannot carry anything but digits by the time it reaches a query builder — defense in depth on top of prepared statements, and the same rule applies to every capture group. Numeric ids, slugs ([a-z0-9-]+), and enum segments each get an explicit pattern; anything that fails all patterns is a 404.

Structure the route table so that "no route" and "wrong method" are separate decisions:

$routes = [
    '#^/articles$#'       => ['GET' => 'index', 'POST' => 'create'],
    '#^/articles/(\d+)$#' => ['GET' => 'show', 'PUT' => 'update', 'DELETE' => 'destroy'],
];

$methods = null; $args = [];
foreach ($routes as $pattern => $table) {
    if (preg_match($pattern, $path, $m)) {
        $methods = $table; $args = array_slice($m, 1);   // captures → handler args
        break;
    }
}
if ($methods === null) {
    http_response_code(404);                              // your URL is wrong
    exit;
}
if (!isset($methods[$method])) {
    header('Allow: ' . implode(', ', array_keys($methods)));
    http_response_code(405);                              // your verb is wrong
    exit;
}
$handler = $methods[$method];                             // 'show' | 'update' | ...

Order matters in two places. Route matching is first-match-wins, so a catch-all pattern must sit at the bottom of the table or it swallows every path before the real routes see it. And the decisions themselves go path first, method second: get it backwards and an unknown path with PATCH returns 405 for a route that does not exist — nonsense the client cannot act on.

Once $handler is a name, the dispatch itself is a match expression with no default fall-through by accident:

$payload = match ($handler) {
    'index'  => $articles->list(filterQuery($_GET)),
    'show'   => $articles->find((int) $args[0]),
    'create' => $articles->create($decoded),   // $decoded from php://input
    'update' => $articles->replace((int) $args[0], $decoded),
    'destroy'=> $articles->delete((int) $args[0]),
    default  => throw new LogicException("Unhandled route: $handler"),
};

That default arm is not defensive noise: it converts a forgotten case into an exception your 500 handler reports with a line number, instead of a silent null response the client receives as an empty body.

Two finishing moves keep the router honest. Canonicalize trailing slashes before matching — /articles/42/ should not be a different resource from /articles/42 — and redirect with 308:

if (str_ends_with($path, '/') && $path !== '/') {
    http_response_code(308);        // Permanent Redirect: method AND body preserved
    header('Location: ' . rtrim($path, '/'));
    exit;
}

308 (or 307) is deliberate: the older 301/302 let clients downgrade a POST to a GET on redirect, silently dropping the body. For an API, a redirect that changes the method is a data-loss bug.

Query parameters are filters, not identity: ?page=2&sort=-created_at gets whitelisted against allowed keys and values — the discipline taught in Validating and Sanitizing Input — while the resource id stays in the path where caches and logs can see it.

With dispatch solved, every URL now reaches one handler that returns one JSON body with one status. The remaining question is the one production forces eventually: your clients are pinned to today's responses, and the first segment of $path — /v1, /v2 — is where you solve that. That is Versioning API Endpoints, with the payload shapes it protects coming from Building JSON Endpoints.

Quiz

1. Requests to /articles/42 reach index.php via rewrite, but PATH_INFO is unset (typical on nginx). Which fallback recovers the path correctly?

Question 1 options

2. GET /articles/42 exists, but this server does not implement PATCH on it. What is the correct response?

Question 2 options

3. Which URL set follows the resource-noun convention for an articles API?

Question 3 options

4. Your router canonicalizes a trailing slash while a client is POSTing with a body. Which redirect status preserves the method and the body?

Question 4 options

Flashcards

Question

What is a front controller, and what does the Apache rewrite pair do?

Answer

Every non-file request is forwarded to one index.php that bootstraps and dispatches. RewriteCond %{REQUEST_FILENAME} !-f and !-d let real assets (css, images) through; RewriteRule ^ index.php [QSA] sends everything else to the controller with the query string appended — nginx equivalent: try_files $uri $uri/ /index.php?$query_string.

Question

How do PATH_INFO, SCRIPT_NAME, and REQUEST_URI differ for /index.php/articles/42?expand=1?

Answer

PATH_INFO = /articles/42 (path appended after the script, present only if the server splits it); SCRIPT_NAME = /index.php (the entry script); REQUEST_URI = /articles/42?expand=1 (raw, always present). Portable routers parse REQUEST_URI with parse_url and strip dirname(SCRIPT_NAME), using PATH_INFO when available.

Question

Which HTTP verbs map to CRUD operations, and what status does each create or delete return?

Answer

GET reads (200), POST creates (201 + Location), PUT replaces idempotently (200/204 — omitted fields are cleared), PATCH partially updates, DELETE removes idempotently (204), OPTIONS reports allowed methods. GET and DELETE are safe/idempotent so caches and retries are harmless; POST is neither, hence Idempotency-Key protection.

Question

Why must a 405 include Allow, and how is 405 different from 404?

Answer

Allow lists the methods the matched route supports (RFC 9110), letting clients and SDKs self-correct. 404 = no path matched (your URL is wrong); 405 = path matched but the method didn't (your verb is wrong). Check path first, then method — the reverse returns 405 for routes that never existed.

Question

How do you extract and harden a path parameter such as /articles/42?

Answer

preg_match with an anchored pattern like #^/articles/(\d+)$#, capture with a strict class such as \d+, then (int) cast before it reaches a query builder. Unanchored patterns match anywhere in the URL and silently widen what the endpoint accepts; anchors plus casting are defense in depth on top of prepared statements.

Revision Notes

Key Takeaways

  • 1. One index.php receives every request; rewrite rules exclude real files, and bootstrap (error handlers, auth, CORS, logging) runs before routing for every path
  • 2. Parse REQUEST_URI with parse_url and strip SCRIPT_NAME's directory; PATH_INFO is a convenience the server may not provide
  • 3. URLs carry plural resource nouns and methods carry verbs: GET reads, POST creates with 201 + Location, PUT replaces, DELETE answers 204
  • 4. Routing decisions run in order — no path match is 404, matched path with wrong verb is 405 plus Allow — so clients always get an actionable answer
  • 5. Anchor every route regex, cast every capture, and canonicalize trailing slashes with 308; 301/302 can downgrade POST to GET and drop the body

Interview Tips

  • • "What is a front controller and why use one?" — a single entry script centralizes bootstrap, error handling, CORS, auth, and logging; contrast with N endpoint files each re-requiring the bootstrap and leaking filenames into URLs
  • • "How does routing work without a framework?" — REQUEST_URI → parse_url → strip base → anchored regex table → cast captures; explicitly state the order: unknown path = 404, known path + wrong verb = 405 with Allow
  • • "POST vs PUT vs PATCH?" — PUT replaces (omitted fields cleared, idempotent), PATCH updates only what was sent, POST creates (201 + Location, not idempotent); mention Idempotency-Key for retried POSTs
  • • "A trailing-slash redirect dropped our POST body — why?" — 301/302 let clients switch the method to GET; 307/308 preserve method and body; 303 is the deliberate convert-to-GET used after a successful POST

Cheat Sheet

Routing Requests in a Front Controller

Web server → one index.php:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA]
location / { try_files $uri $uri/ /index.php?$query_string; }

Reading the path:

Key Value for /index.php/articles/42?expand=1
PATH_INFO /articles/42 — only if the server splits it (Apache yes, nginx usually no)
SCRIPT_NAME /index.php
REQUEST_URI /articles/42?expand=1 — raw, always present
$uri  = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$base = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/');
$path = '/' . trim(substr($uri, strlen($base)), '/');

Method → CRUD:

Method Operation Idempotent Success
GET read yes 200
POST create no 201 + Location
PUT replace (omitted fields cleared) yes 200/204
PATCH partial update — 200
DELETE remove yes 204
OPTIONS allow/preflight yes 200 + Allow

URLs = plural resource nouns, one nesting level: /articles, /articles/42, /articles/42/comments — no verbs.

Dispatch order:

if ($methods === null)      { http_response_code(404); exit; }              // no path match
if (!isset($methods[$method])) {
    header('Allow: ' . implode(', ', array_keys($methods)));
    http_response_code(405); exit;                                          // wrong verb
}

Anchor every pattern (#^/articles/(\d+)$#), (int) cast every capture, put catch-all routes last, canonicalize trailing slashes with 308 (301/302 can downgrade POST → GET and drop the body). Query params are filters — whitelist them.

Continue Learning

Before You Start

Related Reading