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 $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>"; }
GET / GET /about?ref=footer GET /missing
> 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 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']);
GET /products GET /products/42 GET /products/99 POST /products/42/reviews stars=5 GET /products/42/reviews GET /hello/Zo%C3%AB
> 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 $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 // 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+).