Routing and Front Controllers

So far, each URL has matched a PHP file: /contact.php runs contact.php. Modern applications instead send every request through a single entry point — a front controller — which looks at the URL and decides which code should handle it. That gives you clean URLs like /products/42, one place for shared setup, and the structure every PHP framework uses.

A front controller

With PHP's built-in server, passing a script name as the last argument makes it a router script that receives every request:

$
php -S localhost:8000 index.php

In production, the web server is configured to do the same — for example nginx's try_files $uri /index.php?$query_string;, or Apache's FallbackResource /index.php.

The script reads the path from $_SERVER['REQUEST_URI'] and dispatches on it:

PHP index.php
<?php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

switch ($path) {
    case '/':
        echo "<h1>Home</h1>";
        break;
    case '/about':
        echo "<h1>About us</h1>";
        break;
    default:
        http_response_code(404);
        echo "<h1>Page not found</h1>";
}
HTTP
GET /
GET /about?ref=footer
GET /missing
Responses
> GET /
<h1>Home</h1>
> GET /about?ref=footer
<h1>About us</h1>
> GET /missing
< 404 Not Found
<h1>Page not found</h1>

parse_url(…, PHP_URL_PATH) strips the query string, so /about?ref=footer still matches /about; $_GET works as usual.

A route table with parameters

A switch doesn't scale, and real routes contain parameters such as a product ID. A small router maps an HTTP method and a URL pattern to a handler, turning {id} placeholders into regular-expression groups:

PHP index.php
<?php
declare(strict_types=1);

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

    public function add(string $method, string $pattern, callable $handler): void
    {
        $regex = '#^' . preg_replace('/\{(\w+)\}/', '(?<$1>[^/]+)', $pattern) . '$#';
        $this->routes[] = [$method, $regex, $handler];
    }

    public function dispatch(string $method, string $uri): string
    {
        $path = rawurldecode(parse_url($uri, PHP_URL_PATH));
        $allowed = [];
        foreach ($this->routes as [$routeMethod, $regex, $handler]) {
            if (!preg_match($regex, $path, $m)) {
                continue;
            }
            if ($routeMethod !== $method) {
                $allowed[] = $routeMethod;
                continue;
            }
            $params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
            return $handler(...$params);
        }
        http_response_code($allowed ? 405 : 404);
        return $allowed ? 'Method not allowed' : 'Not found';
    }
}

$products = [42 => 'Desk lamp', 7 => 'Notebook'];

$router = new Router();
$router->add('GET', '/', fn() => 'Welcome!');
$router->add('GET', '/products', fn() => implode(', ', $products));
$router->add('GET', '/products/{id}', function (string $id) use ($products) {
    if (!isset($products[(int) $id])) {
        http_response_code(404);
        return "No product $id";
    }
    return "Product $id: " . $products[(int) $id];
});
$router->add('POST', '/products/{id}/reviews', fn(string $id) => "Review saved for product $id");
$router->add('GET', '/hello/{name}', fn(string $name) => 'Hello, ' . htmlspecialchars($name) . '!');

echo $router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
HTTP
GET /products
GET /products/42
GET /products/99
POST /products/42/reviews stars=5
GET /products/42/reviews
GET /hello/Zo%C3%AB
Responses
> GET /products
Desk lamp, Notebook
> GET /products/42
Product 42: Desk lamp
> GET /products/99
< 404 Not Found
No product 99
> POST /products/42/reviews
Review saved for product 42
> GET /products/42/reviews
< 405 Method Not Allowed
Method not allowed
> GET /hello/Zo%C3%AB
Hello, Zoë!

Named groups become named arguments for the handler ($handler(...$params) — lesson 26). The router tells "this URL doesn't exist" (404) apart from "this URL exists, but not for that method" (405).

Controllers

As an application grows, handlers move out of closures into controller classes, one method per action. Routes then refer to them, and the router creates the controller:

PHP
<?php
$router->add('GET', '/products/{id}', [ProductController::class, 'show']);
$router->add('POST', '/products', [ProductController::class, 'store']);

final class ProductController
{
    public function __construct(private ProductRepository $products) {}

    public function show(string $id): string
    {
        $product = $this->products->find((int) $id) ?? throw new NotFoundException();
        return render('products/show', ['product' => $product]);
    }
}

A typical request then flows: front controller → router → controller → model/repository → template → response. This is the Model–View–Controller (MVC) structure used by Laravel, Symfony and most PHP applications.

Serving static files

When a router script handles every request, the built-in server also sends requests for images, CSS and JavaScript through it. Returning false from the router script tells the server to serve the requested file as-is:

PHP
<?php
// at the top of index.php when using `php -S localhost:8000 index.php`
if (PHP_SAPI === 'cli-server' && is_file(__DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH))) {
    return false;
}

nginx and Apache handle this themselves with the try_files/FallbackResource rules shown earlier.

Use a library in real projects

Writing a router is a great way to understand how frameworks work, but production applications usually use a well-tested one: Laravel's and Symfony's routers, or standalone libraries such as FastRoute or league/route installed with Composer (lesson 60). They add route groups, middleware, named routes for generating URLs, and caching for speed.

Try it yourself

Extend the router with a route for /products/{id}/edit, and a route that only accepts numeric IDs by changing the placeholder pattern to (\d+).

Try it yourself · index.php
Edit the code, copy it into index.php, then start PHP's built-in web server with php -S localhost:8000 in that folder and open http://localhost:8000/index.php (PHP 8.5). How to install PHP