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?
2. GET /articles/42 exists, but this server does not implement PATCH on it. What is the correct response?
3. Which URL set follows the resource-noun convention for an articles API?
4. Your router canonicalizes a trailing slash while a client is POSTing with a body. Which redirect status preserves the method and the body?
Flashcards
Question
What is a front controller, and what does the Apache rewrite pair do?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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.