Practical Capstone Projects

Minimal Router

A router maps an HTTP method and URL path to a known handler. Build one small router for the product application so every request enters through public/index.php, while controllers remain ordinary PHP callables.

public/index.php
src/Http/Response.php
src/Http/Router.php

Run the project from its web root:

php -S 127.0.0.1:8000 -t public public/index.php

This command is for local development. Production should configure its web server to serve only public/ and forward application routes to the front controller.

Give Responses One Owner

Put the HTTP result in src/Http/Response.php:

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.');
        }
    }

    public function send(bool $withoutBody = false): void
    {
        http_response_code($this->status);
        foreach ($this->headers as $name => $value) {
            if (!is_string($name) || !is_string($value)
                || preg_match('/\A[A-Za-z0-9-]+\z/D', $name) !== 1
                || preg_match('/[\x00-\x1F\x7F]/', $value) === 1) {
                throw new RuntimeException('Invalid response header.');
            }
            header($name . ': ' . $value);
        }

        if (!$withoutBody) {
            echo $this->body;
        }
    }
}

Controllers return a Response; they do not call header(), set status codes, and print unrelated fragments independently. The front controller sends exactly one result.

Match Explicit Patterns

Put the route table and dispatch rules in src/Http/Router.php:

PHP example
<?php

declare(strict_types=1);

final class Router
{
    private array $routes = [];

    public function add(string $method, string $pattern, callable $handler): void
    {
        $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,
        ];
    }

    public function dispatch(string $method, string $path): Response
    {
        $method = strtoupper($method);
        $dispatchMethod = $method === 'HEAD' ? 'GET' : $method;
        $allowed = [];

        foreach ($this->routes as $route) {
            $matched = preg_match($route['pattern'], $path, $captures);
            if ($matched === false) {
                throw new LogicException('A configured route pattern is invalid.');
            }
            if ($matched !== 1) {
                continue;
            }

            $allowed[] = $route['method'];
            if ($route['method'] !== $dispatchMethod) {
                continue;
            }

            $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 ($allowed !== []) {
            if (in_array('GET', $allowed, true)) {
                $allowed[] = 'HEAD';
            }
            $allowed = array_values(array_unique($allowed));
            sort($allowed);

            return new Response(
                "Method Not Allowed\n",
                405,
                ['Allow' => implode(', ', $allowed), 'Content-Type' => 'text/plain; charset=utf-8'],
            );
        }

        return new Response(
            "Not Found\n",
            404,
            ['Content-Type' => 'text/plain; charset=utf-8'],
        );
    }
}

Patterns are written by the application, never constructed from requested filenames or class names. Static patterns are anchored, and parameter patterns accept only the intended syntax:

PHP example
<?php

// no-execute: requires configured router and controller instances.
declare(strict_types=1);

$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']);

Register the static /products/create route before the numeric detail route for readability, although the numeric constraint prevents the word create from being captured as an ID.

Parse The Request Target Once

The front controller parses the path, dispatches, and converts unexpected failures into a generic response:

PHP example
<?php

declare(strict_types=1);

// no-execute: requires Composer or project autoloading and configured controllers.
require dirname(__DIR__) . '/vendor/autoload.php';

$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']);

$method = is_string($_SERVER['REQUEST_METHOD'] ?? null)
    ? $_SERVER['REQUEST_METHOD']
    : 'GET';
$requestTarget = $_SERVER['REQUEST_URI'] ?? '';
$path = is_string($requestTarget) ? parse_url($requestTarget, PHP_URL_PATH) : false;

try {
    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);
    }
} 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');

parse_url() removes the query string from matching, so /products?page=2 still matches /products. This router deliberately treats trailing slashes as different paths and does not decode the whole path; decoding %2F before matching could turn data into a new path segment. Controllers must still validate captured values, cast a checked numeric ID, and return 404 when the record does not exist.

Distinguish HTTP Outcomes

  • 400 means the request target could not produce a valid absolute path.
  • 404 means no configured path matched, including /products/not-a-number.
  • 405 means the path exists under another method; the response includes Allow.
  • HEAD uses the matching GET handler and headers but suppresses the response body.
  • 500 hides exception details from the client while recording a controlled diagnostic.

This router intentionally omits middleware, route caching, host routing, URL generation, dependency injection, and nested groups. Use a maintained framework router when the application needs those features.

Verify The Surface

Use curl -i to check GET list, GET detail, query strings, HEAD, create POST, unknown path, unsupported method, zero and non-numeric IDs, trailing slash policy, and a forced handler exception. Confirm every request produces one status, one header set, and no leaked stack trace.

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.