Routing means mapping a request (the URL path and the HTTP method) to the code that should handle it.
| Request | Handler |
|---|---|
GET / | Home page |
GET /products | Product list |
GET /products/42 | Show product 42 |
POST /products | Create a product |
A small router
<?php
final class Router
{
/** @var array<int, array{method: string, regex: string, handler: callable}> */
private array $routes = [];
public function add(string $method, string $pattern, callable $handler): void
{
// "/products/{id}" becomes the regular expression #^/products/(?<id>[^/]+)$#
$regex = "#^" . preg_replace('#\{(\w+)\}#', '(?<$1>[^/]+)', $pattern) . "$#";
$this->routes[] = ["method" => $method, "regex" => $regex, "handler" => $handler];
}
public function dispatch(string $method, string $path): array
{
$pathMatched = false;
foreach ($this->routes as $route) {
if (preg_match($route["regex"], $path, $matches) !== 1) {
continue;
}
$pathMatched = true;
if ($route["method"] !== $method) {
continue;
}
$params = array_filter($matches, "is_string", ARRAY_FILTER_USE_KEY);
return ($route["handler"])($params);
}
return $pathMatched
? ["status" => 405, "body" => "Method not allowed"]
: ["status" => 404, "body" => "Not found"];
}
}
$router = new Router();
$router->add("GET", "/", fn() => ["status" => 200, "body" => "Home"]);
$router->add("GET", "/products/{id}", fn(array $p) => ["status" => 200, "body" => "Product " . (int) $p["id"]]);
$router->add("POST", "/products", fn() => ["status" => 201, "body" => "Created"]);
foreach ([["GET", "/"], ["GET", "/products/42"], ["POST", "/products"], ["DELETE", "/products"], ["GET", "/missing"]] as [$method, $path]) {
$r = $router->dispatch($method, $path);
echo "$method $path -> {$r['status']} {$r['body']}\n";
}
GET / -> 200 Home
GET /products/42 -> 200 Product 42
POST /products -> 201 Created
DELETE /products -> 405 Method not allowed
GET /missing -> 404 Not found
How it works
add()turns a pattern with{name}placeholders into a regular expression with named groups.dispatch()tests each route. If the path matches but the method does not, it remembers that.- It passes the named parameters to the handler (
["id" => "42"]). - No match:
404. A path that exists with another method:405.
Route parameters are strings from the URL
$p["id"] is the text "42", and it comes from the visitor. Validate it, cast it, and use a placeholder in SQL.
<?php
function intParam(array $params, string $name): ?int
{
$v = filter_var($params[$name] ?? null, FILTER_VALIDATE_INT);
return ($v === false || $v < 1) ? null : $v;
}
var_dump(intParam(["id" => "42"], "id"));
var_dump(intParam(["id" => "abc"], "id"));
var_dump(intParam(["id" => "-3"], "id"));
int(42)
NULL
NULL
Route groups and names
Real routers add groups (all routes under /admin need a login), named routes (generate a URL from a name) and middleware (code that runs before or after a handler). The idea is the same as above.
Do not trust the path
Normalise the path before matching (remove a trailing slash, ignore the query string with parse_url(..., PHP_URL_PATH)). Reject paths that contain .. if you ever map them to files.
Use a library when it grows
For a real project, use a tested router: FastRoute (a small, fast package), or the router that comes with Symfony or Laravel. Writing one is a great way to understand them, but production routers handle many edge cases (caching, optional segments, host matching).
composer require nikic/fast-route
Why this matters in Laravel
Route::get("/products/{id}", ...) is this idea with groups, middleware, model binding and a compiled route cache. The {id} placeholder works the same way.