Routing: Matching URLs and HTTP Methods to Code
Learn how a router matches static and dynamic routes, enforces constraints and HTTP methods, groups related routes, supports named routes for URL generation, and handles unmatched requests with a proper 404.
Once a front controller receives every request, something has to decide what that request actually means: is GET /users/42 a request to view user 42, and is there even a route registered for it at all? That's the router's job — a lookup table (implemented as code) mapping an HTTP method and a URL pattern to the controller action that should handle it.
Static routes vs dynamic routes
A static route matches an exact, fixed path — /about, /contact, /pricing. There's no variation to capture; the router just compares the incoming path to a literal string.
A dynamic (parameterized) route contains one or more placeholders, conventionally written {name}, that match a segment of the URL and capture its value — /users/{id} matches /users/42 and /users/abc equally, extracting id as "42" or "abc" for the controller to use. Internally, a router typically converts a pattern like /users/{id} into a regular expression such as #^/users/(?P<id>[^/]+)$# and tests incoming paths against it.
Constraints: restricting what a parameter can match
By default, {id} matches any non-slash characters, which means /users/abc would match a route meant only for numeric ids. A constraint narrows that with its own regex, so /users/{id} with a constraint of \d+ only matches when id is one or more digits — /users/abc then correctly falls through to a 404 (or to a different, more specific route) instead of reaching a controller that expects an integer.
Constraints are how a router disambiguates between routes that would otherwise overlap, such as /users/{id} (numeric) versus /users/{slug} (anything else) — without them, whichever route is registered first would always win, silently swallowing requests meant for the other.
Matching by HTTP method
The same URL commonly means different things depending on the HTTP method: GET /users lists users, POST /users creates one. A router must therefore match on the (method, path) pair together, not the path alone — a route registered only for GET should never handle a POST to the same path, and a well-behaved router responds to that mismatch with 405 Method Not Allowed rather than silently ignoring the method or treating it as a 404.
Route groups: shared prefixes and middleware
Real applications end up with many routes that share a common prefix and common requirements — every /admin/* route needs authentication, every /api/v1/* route needs a JSON content type. Repeating that on every single route call is repetitive and error-prone (forget it once, and that one route is unprotected).
A route group lets you declare a shared prefix and a shared list of middleware once, and register several routes underneath it; the router concatenates the prefix and attaches the middleware to every route defined inside the group automatically.
Named routes and URL generation
Hard-coding URLs throughout templates and redirects (return redirect('/users/' . $id . '/edit')) means every one of those strings breaks silently if the route's path ever changes. A named route lets you attach a short identifier ('users.edit') to a route definition once, and then generate its URL anywhere by name and parameters instead of by hand-built string: $router->urlFor('users.edit', ['id' => 42]) produces /users/42/edit by substituting into the route's own pattern.
This means a route's path can change in exactly one place (its definition) without needing to hunt down every place that linked to it.
Handling no match: the 404 case
Not every incoming request will match a registered route — a typo in the URL, a bot probing for common paths, or a link to a page that was removed. The router must have an explicit fallback: if no route's method-and-pattern combination matches, return a proper 404 Not Found response (with a helpful body) rather than letting PHP emit a fatal error or, worse, silently returning a 200 with an empty page.
Distinguishing 'no path matched at all' (404) from 'the path matched but not for this HTTP method' (405) is a small but meaningful correctness detail — a 405 response should also include an Allow header listing which methods the path does support.
Converting a route pattern to a regex
{id} becomes a named capture group (?P<id>...) so the matched value can be pulled out by parameter name later. Without an explicit constraint, a placeholder defaults to [^/]+ — any run of characters that isn't a slash. Anchoring with ^ and $ prevents a pattern like /users from accidentally matching /users/42/extra.
private function patternToRegex(string $pattern, array $constraints): string
{
$regex = preg_replace_callback(
'#\{(\w+)\}#',
function (array $m) use ($constraints) {
$name = $m[1];
$rule = $constraints[$name] ?? '[^/]+';
return "(?P<{$name}>{$rule})";
},
$pattern
);
return '#^' . $regex . '$#';
}Code Examples
This router stores routes per HTTP method, converts each pattern to an anchored regex with named capture groups, and on dispatch tries every registered pattern for a path match before checking the method — so it can correctly distinguish a true 404 (no path matched at all) from a 405 (the path matched, but not with this method).
if ($routeMethod !== $method) { $allowedMethods[] = $routeMethod; continue; } is what lets the router tell a 404 apart from a 405.
The group() call temporarily prefixes every route registered inside its callback with /admin, so admin.dashboard actually resolves to GET /admin/dashboard without repeating the prefix on each line. urlFor() looks up a route purely by its name, so if /users/{id} is ever renamed to /members/{id}, only the route definition changes — every urlFor('users.show', ...) call keeps working.
Common mistake: Registering /users/{slug} before /users/{id} with no constraints means the slug route always wins, because the router tries patterns in registration order and both patterns match any non-slash text — /users/42 would never reach the numeric-id route.
parse_url(..., PHP_URL_PATH) strips off any query string before the router ever sees the path, and trimming a trailing slash means /users and /users/ are treated as the same route rather than requiring two separate registrations.
<?php
declare(strict_types=1);
namespace App;
use App\Http\{Request, Response};
final class Router
{
private array $routes = []; // [method => [ [regex, paramNames, handler, name] ]]
private string $groupPrefix = '';
public function get(string $pattern, callable $handler, array $constraints = [], ?string $name = null): void
{
$this->add('GET', $pattern, $handler, $constraints, $name);
}
public function post(string $pattern, callable $handler, array $constraints = [], ?string $name = null): void
{
$this->add('POST', $pattern, $handler, $constraints, $name);
}
public function group(string $prefix, callable $callback): void
{
$previousPrefix = $this->groupPrefix;
$this->groupPrefix .= $prefix;
$callback($this);
$this->groupPrefix = $previousPrefix;
}
private function add(string $method, string $pattern, callable $handler, array $constraints, ?string $name): void
{
$fullPattern = $this->groupPrefix . $pattern;
preg_match_all('#\{(\w+)\}#', $fullPattern, $paramMatches);
$this->routes[$method][] = [
'regex' => $this->patternToRegex($fullPatter
…<?php
$router = new App\Router();
// static route
$router->get('/about', fn ($req, $params) => Response::html('<h1>About</h1>'));
// dynamic route with a numeric constraint, and a name for URL generation
$router->get(
'/users/{id}',
[$userController, 'show'],
constraints: ['id' => '\d+'],
name: 'users.show'
);
// a group sharing a prefix and (conceptually) shared middleware
$router->group('/admin', function (App\Router $router) {
$router->get('/dashboard', [$adminController, 'dashboard'], name: 'admin.dashboard');
$router->get('/users/{id}/edit', [$adminController, 'editUser'], ['id' => '\d+'], 'admin.users.edit');
});
// generating a URL from a named route instead of hand-building the string
echo $router->urlFor('users.show', ['id' => 42]); // /users/42
echo $router->urlFor('admin.users.edit', ['id' => 7]); // /admin/users/7/edit<?php
declare(strict_types=1);
namespace App\Http;
final class Request
{
private function __construct(
private string $method,
private string $path,
private array $headers,
private string $body,
) {}
public static function fromGlobals(): self
{
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
return new self(
method: strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'),
path: rtrim($uri, '/') ?: '/',
headers: getallheaders() ?: [],
body: file_get_contents('php://input') ?: '',
);
}
public function method(): string { return $this->method; }
public function path(): string { return $this->path; }
public function header(string $name): ?string { return $this->headers[$name] ?? null; }
public function body(): string { return $this->body; }
}Route matching decision flow
The router always finishes checking every route's path before deciding between 404 and 405, so a path that exists under a different method is reported correctly instead of looking like it doesn't exist.
Request: GET /users/42
│
▼
Try each registered pattern, in order
│
├─▶ /about no path match
├─▶ /users/{id} path MATCHES, method GET == GET ──▶ dispatch to handler, params={id:42}
│ (if method mismatched: remember it, keep looking)
└─▶ /admin/dashboard no path match
If no pattern's path matched at all ──▶ 404 Not Found
If a path matched but method didn't ──▶ 405 Method Not Allowed + Allow headerImportant Rules
A route is matched on the (HTTP method, path pattern) pair together — never on the path alone.
Dynamic segments default to matching any non-slash characters; add a regex constraint whenever a segment should be restricted (e.g. numeric-only ids).
Route groups share a prefix (and, in real routers, shared middleware) across every route registered inside them, so the prefix and cross-cutting concerns are declared once.
Named routes decouple a route's identity from its literal path string, so paths can change without breaking every place that links to them.
When no route's path matches, respond 404; when the path matches but not the method, respond 405 with an Allow header listing the supported methods.
Attribute-based and file-based routing in frameworks
Symfony and Laravel both build on exactly this model but let you declare routes in different ways: Symfony supports PHP 8 attributes directly on controller methods (#[Route('/users/{id}', methods: ['GET'], requirements: ['id' => '\d+'], name: 'users.show')]), while Laravel typically centralizes routes in routes/web.php or routes/api.php using a fluent Route:: facade. Both compile down to the same core idea covered here: a table of (method, pattern, constraints, handler, name) matched against every incoming request.
$router->get('/users/{slug}', $slugHandler); $router->get('/users/{id}', $idHandler, ['id' => '\d+']); // registered in this order, /users/42 hits $slugHandler first
$router->get('/users/{id}', $idHandler, ['id' => '\d+']); $router->get('/users/{slug}', $slugHandler); // more specific / constrained route registered first
Most simple routers try patterns in registration order and stop at the first match. Placing the more general, unconstrained pattern first means it silently intercepts requests meant for the more specific route — always register the most specific or most constrained routes earlier.
A route is registered as GET /users/{id} with constraint ['id' => '\d+']. What happens on a request to GET /users/abc? A. It matches, with id bound to the string 'abc' B. It does not match this route (fails the constraint), so the router looks for another match or falls through to 404 C. It throws a fatal PHP error D. It matches and id is automatically cast to 0
B. It does not match this route (fails the constraint), so the router looks for another match or falls through to 404 — The constraint restricts {id} to one or more digits, so 'abc' fails the regex and the route simply does not match — the router continues checking other routes, or returns 404 if none match.
A path matches a registered route's pattern, but the request's HTTP method is POST while the route is registered only for GET. What should the router return? A. 200 OK, ignoring the method B. 404 Not Found C. 405 Method Not Allowed, ideally with an Allow header listing supported methods D. 301 Moved Permanently
C. 405 Method Not Allowed, ideally with an Allow header listing supported methods — 404 means no path matched at all; here the path did match, just not for this method, so the correct, more informative response is 405 Method Not Allowed.
[code] What is the purpose of a named route such as name: 'users.show'? A. It makes the route match faster B. It lets code generate that route's URL by name and parameters (urlFor), instead of hard-coding the literal path string everywhere C. It restricts the route to authenticated users D. It is required for every route to be valid PHP
B. It lets code generate that route's URL by name and parameters (urlFor), instead of hard-coding the literal path string everywhere — A named route decouples the route's identity from its path string. urlFor('users.show', ['id' => 42]) builds the correct URL from the route's current definition, so the path can change in one place without breaking links built elsewhere.
Add a route DELETE /users/{id} (constrained to digits) to the router above, named 'users.destroy'. Then write the code that, given no matching route exists for PATCH /users/abc, correctly returns a 404 (not a 405) — explain in a comment why it's a 404 rather than a 405.
$router->delete('/users/{id}', [$userController, 'destroy'], ['id' => '\d+'], 'users.destroy'); // PATCH /users/abc: 'abc' fails the \d+ constraint on every /users/{id} route, // so no route's regex matches this path at all (path-level mismatch), // which the dispatch() loop reports as a 404 — 405 is reserved for requests // whose PATH matched a route but whose METHOD did not.
PATCH /users/abc fails the \d+ constraint on {id}, so it never matches the GET or DELETE /users/{id} patterns at all — meaning the path itself doesn't match any route, which is the definition of a 404, not a 405 (405 requires the PATH to have matched under a different method).