0

Route matching — regex compilation from {param} patterns

Advanced5 min read·fw-03-002

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:

  1. Filter by HTTP method — only check routes registered for the request's method.
  2. 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
<?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']