Route matching — regex compilation from {param} patterns
Concept
Route matching converts URI patterns like /users/{id} into regular expressions, then tests incoming request URIs against them to find the correct route.
The compilation step: {param} → regex named capture group. /users/{id} becomes #^/users/(?P<id>[^/]+)$#. {id} with ->where('id', '[0-9]+') becomes (?P<id>[0-9]+). Optional params {name?} become (?:/[^/]+)?.
Two-phase matching:
- Filter by HTTP method — only check routes registered for the request's method.
- Test each route's compiled regex against the request URI. First match wins.
Named capture groups: (?P<id>[^/]+) makes the captured value accessible as $matches['id'] from preg_match(). This is how route parameters are extracted.
preg_match() return value: Returns 1 on match, 0 on no match, false on error. Check === 1 not truthy.
Method Not Allowed (405) vs Not Found (404): If a URI matches a route pattern but NOT with the correct HTTP method, that's 405 — not 404. The router must distinguish these. To do so: match all routes (not just the request's method), check if any matched on the URI. If yes → 405 (wrong method). If no → 404 (no match at all).
Performance consideration: For apps with hundreds of routes, a compiled flat regex (combining all routes into one with alternation) is faster than looping. Laravel's router uses this optimization.
Code Example
<?php
namespace Framework\Routing;
class RouteMatcher
{
public function match(RouteCollection $routes, string $method, string $uri): MatchedRoute
{
$method = strtoupper($method);
// Phase 1: Check routes for the specific method
foreach ($routes->getByMethod($method) as $route) {
$params = $this->matchRoute($route, $uri);
if ($params !== null) {
return new MatchedRoute($route, $params);
}
}
// Phase 2: Check all routes — were there URI matches with wrong method?
$uriMatchedMethods = [];
foreach ($routes->all() as $route) {
if ($this->matchRoute($route, $uri) !== null) {
$uriMatchedMethods[] = $route->method;
}
}
if (!empty($uriMatchedMethods)) {
// URI matched but wrong method → 405
throw new \Framework\Routing\MethodNotAllowedException(
array_unique($uriMatchedMethods)
);
}
// No match at all → 404
throw new \Framework\Routing\RouteNotFoundException($uri);
}
private function matchRoute(Route $route, string $uri): ?array
{
$pattern = $route->getCompiledPattern();
if (preg_match($pattern, $uri, $matches) !== 1) {
return null;
}
// Extract only named captures (string keys), not numeric
return array_filter(
$matches,
fn($key) => is_string($key),
ARRAY_FILTER_USE_KEY
);
}
}
class MatchedRoute
{
public function __construct(
public readonly Route $route,
public readonly array $params, // ['id' => '42', 'slug' => 'hello']
) {}
}
// Example compilation and matching
// Route: GET /users/{id}
// Compiled: #^/users/(?P<id>[^/]+)$#
// URI: /users/42 → match, params: ['id' => '42']
// URI: /users/42/posts → no match
// Route: GET /posts/{year}/{slug?}
// Compiled: #^/posts/(?P<year>[0-9]{4})(?:/(?P<slug>[^/]+))?$#
// URI: /posts/2024 → match, params: ['year' => '2024', 'slug' => '']
// URI: /posts/2024/my-post → match, params: ['year' => '2024', 'slug' => 'my-post']