Practical Capstone Projects

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 example
<?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():

PHP example
// 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:

PHP example
// 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:

PHP example
// 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:

PHP example
// 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:

PHP example
// 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:

PHP example
// 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:

PHP example
// 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 through public/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 generic 500 outcomes deliberately.
  • Include every supported method in the Allow header for 405.
  • Treat HEAD as 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 example
<?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 example
<?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.