Minimal Router
php -S 127.0.0.1:8000 -t public public/index.php
Production serves only public/ and forwards application routes to public/index.php.
Represent The Result Before Sending It
A controller should return data describing the response:
<?php
declare(strict_types=1);
final class Response
{
public function __construct(
public readonly string $body = '',
public readonly int $status = 200,
public readonly array $headers = [],
) {
if ($status < 100 || $status > 599) {
throw new InvalidArgumentException('Invalid HTTP status.');
}
}
}
This keeps controllers from printing fragments and setting unrelated headers throughout a request.
Sending is a separate responsibility. Validate application-produced headers before calling header():
// Partial: Response::send(bool $withoutBody).
http_response_code($this->status);
foreach ($this->headers as $name => $value) {
$validName = is_string($name)
&& preg_match('/\A[A-Za-z0-9-]+\z/D', $name) === 1;
$validValue = is_string($value)
&& preg_match('/[\x00-\x1F\x7F]/', $value) !== 1;
if (!$validName || !$validValue) {
throw new RuntimeException('Invalid response header.');
}
header($name . ': ' . $value);
}
if (!$withoutBody) {
echo $this->body;
}
HEAD uses normal GET status and headers but passes true to suppress the body.
Register Only Application-Owned Patterns
The route table stores method, anchored regex, and callable:
// Partial: Router::add().
$method = strtoupper($method);
if (!in_array($method, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], true)) {
throw new InvalidArgumentException('Unsupported route method.');
}
if (@preg_match($pattern, '') === false) {
throw new InvalidArgumentException('Invalid route pattern.');
}
$this->routes[] = [
'method' => $method,
'pattern' => $pattern,
'handler' => $handler,
];
Route patterns come from code, never from a request-chosen filename or class name.
Register static and constrained routes explicitly:
// no-execute: requires configured router and controller instances.
$router->add('GET', '#\A/products\z#D', [$controller, 'index']);
$router->add('GET', '#\A/products/create\z#D', [$controller, 'create']);
$router->add('GET', '#\A/products/(?P<id>[1-9][0-9]*)\z#D', [$controller, 'show']);
$router->add('POST', '#\A/products\z#D', [$controller, 'store']);
A zero, sign, slash, or word cannot enter the numeric capture.
Match Path Before Method Failure
HEAD dispatches through GET. While scanning, remember every method whose path matched:
// Partial: start of Router::dispatch().
$method = strtoupper($method);
$dispatchMethod = $method === 'HEAD' ? 'GET' : $method;
$allowed = [];
foreach ($this->routes as $route) {
$matched = preg_match($route['pattern'], $path, $captures);
if ($matched !== 1) {
continue;
}
$allowed[] = $route['method'];
if ($route['method'] !== $dispatchMethod) {
continue;
}
For the matching method, keep only named captures and call the handler:
// Partial: matching branch inside the loop.
$parameters = array_filter(
$captures,
static fn (int|string $key): bool => is_string($key),
ARRAY_FILTER_USE_KEY,
);
$response = ($route['handler'])($parameters);
if (!$response instanceof Response) {
throw new LogicException('Route handlers must return Response.');
}
return $response;
If the loop found the path under other methods, return 405 and an Allow header. Add HEAD whenever GET is allowed. If $allowed remains empty, return 404.
Parse The Request Target Once
The front controller ignores the query string for route matching:
// Partial: public/index.php before dispatch.
$method = is_string($_SERVER['REQUEST_METHOD'] ?? null)
? $_SERVER['REQUEST_METHOD']
: 'GET';
$target = $_SERVER['REQUEST_URI'] ?? '';
$path = is_string($target) ? parse_url($target, PHP_URL_PATH) : false;
if (!is_string($path) || $path === '' || $path[0] !== '/') {
$response = new Response("Bad Request\n", 400, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
} else {
$response = $router->dispatch($method, $path);
}
This router deliberately treats trailing slashes as distinct and does not decode the entire path. Decoding an encoded slash before matching could turn data into another segment.
Wrap dispatch in the application's outer try/catch. Log controlled diagnostic context, return generic 500, then send exactly once:
// Partial: outer failure and final send.
try {
$response = $router->dispatch($method, $path);
} catch (Throwable $exception) {
error_log('Unhandled request failure: ' . $exception::class);
$response = new Response("Internal Server Error\n", 500, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
}
$response->send(strtoupper($method) === 'HEAD');
In the actual front controller, keep path validation and dispatch inside one outer try without dispatching twice; the snippets isolate the decisions for study.
Verify The Surface
Test GET, query strings, numeric capture, HEAD, POST, unknown path, wrong method and Allow, zero/non-numeric IDs, trailing slash, malformed target, invalid handler return, invalid configured regex, and a thrown handler. This router intentionally omits middleware, URL generation, route caching, and groups; use a maintained framework router when those become requirements.
Practice
Practice: Build A Tiny Product Router
Implement the response object, router, and front controller from the lesson for product list, detail, create-form, and create-POST routes.
Requirements
- Serve only
public/and route application requests throughpublic/index.php. - Parse the URL path once without matching its query string.
- Match anchored application-defined patterns, never dynamic include paths or class names.
- Accept only positive decimal product IDs and pass named captures to handlers.
- Require every handler to return a
Response. - Return
400,404,405, and generic500outcomes deliberately. - Include every supported method in the
Allowheader for405. - Treat
HEADas GET dispatch while suppressing its body. - State and verify the trailing-slash and percent-decoding policies.
- Validate response status and reject header values containing CR or LF.
- Log only controlled failure context; do not expose exceptions or stack traces.
Verify normal routes, query strings, HEAD, unknown paths, wrong methods, invalid IDs, trailing slashes, malformed targets, invalid handler returns, and thrown handlers.
Show solution
<?php
declare(strict_types=1);
// no-execute: requires project autoloading and the product controller.
$router = new Router();
$router->add('GET', '#\A/products\z#D', [$productController, 'index']);
$router->add('GET', '#\A/products/create\z#D', [$productController, 'create']);
$router->add('GET', '#\A/products/(?P<id>[1-9][0-9]*)\z#D', [$productController, 'show']);
$router->add('POST', '#\A/products\z#D', [$productController, 'store']);
The detail controller validates the captured string again at its boundary:
<?php
declare(strict_types=1);
function showProduct(array $parameters, ProductRepository $products): Response
{
$rawId = $parameters['id'] ?? null;
$id = is_string($rawId)
? filter_var($rawId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]])
: false;
if ($id === false) {
return new Response("Not Found\n", 404, ['Content-Type' => 'text/plain; charset=utf-8']);
}
$product = $products->find($id);
if ($product === null) {
return new Response("Not Found\n", 404, ['Content-Type' => 'text/plain; charset=utf-8']);
}
return new Response((string) $product['name'] . "\n", 200, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
}
Run the server and inspect the contract:
php -S 127.0.0.1:8000 -t public public/index.php
curl -i http://127.0.0.1:8000/products
curl -I http://127.0.0.1:8000/products
curl -i -X DELETE http://127.0.0.1:8000/products
curl -i http://127.0.0.1:8000/products/not-a-number
curl -i http://127.0.0.1:8000/products/
The DELETE response is 405 with Allow: GET, HEAD, POST. The invalid ID and trailing slash are 404. HEAD returns the same status and headers as GET without a body. A forced exception returns generic 500 text and logs only the controlled diagnostic.