How a PHP Router Actually Works: Building Route Matching, Parameters, and Caching From Scratch

A router's job is simple to state and easy to get wrong in practice: given an incoming URL and HTTP method, find the matching registered route and extract any parameters from the URL. This article builds that mechanism from scratch — route registration, parameter extraction, route groups, and the caching layer that becomes necessary once a route table grows — continuing the router component introduced in our overview of the core components of a modern PHP framework.

 

Step 1: The Simplest Possible Router

At its core, a router is just a lookup table matching a method and path to a handler:

```php
class Router
{
   private array $routes = [];

 

   public function get(string $path, callable $handler): void
   {
       $this->routes['GET'][$path] = $handler;
   }

   public function dispatch(string $method, string $path)
   {
       if (! isset($this->routes[$method][$path])) {
           throw new RouteNotFoundException();
       }

       return ($this->routes[$method][$path])();
   }
}
```
This works for static paths like /about, but breaks immediately for anything with a dynamic segment, like /orders/42.

 

Step 2: Route Parameters

Real routes need placeholders — /orders/{id} — that capture part of the URL as a named parameter. This means routes can no longer be matched with a plain array lookup; each route needs to be converted into a pattern that can be tested against the incoming path:


```php
class Route
{
   private string $pattern;
   private array $paramNames = [];

 

   public function __construct(private string $path, private $handler)
   {
       $this->paramNames = [];

       $regex = preg_replace_callback('#\{(\w+)\}#', function ($matches) {
           $this->paramNames[] = $matches[1];
           return '([^/]+)';
       }, $path);

       $this->pattern = '#^' . $regex . '$#';
   }

   public function match(string $requestPath): ?array
   {
       if (! preg_match($this->pattern, $requestPath, $matches)) {
           return null;
       }

       array_shift($matches);

       return array_combine($this->paramNames, $matches);
   }

   public function handler()
   {
       return $this->handler;
   }
}

class Router
{
   private array $routes = [];

   public function get(string $path, callable $handler): void
   {
       $this->routes['GET'][] = new Route($path, $handler);
   }

   public function dispatch(string $method, string $path)
   {
       foreach ($this->routes[$method] ?? [] as $route) {
           $params = $route->match($path);

           if ($params !== null) {
               return ($route->handler())(...array_values($params));
           }
       }

       throw new RouteNotFoundException();
   }
}
```
Now /orders/{id} matches /orders/42 and passes 42 as an argument to the handler, extracted via a capturing group in the compiled regex.

 

Step 3: Route Groups (Shared Prefixes and Middleware)

Applications quickly need to group routes — all admin routes under /admin, sharing an authentication check. Rather than repeating the prefix and middleware on every route, a group mechanism applies them to every route registered inside it:


```php
class Router
{
   private array $routes = [];
   private string $currentPrefix = '';
   private array $currentMiddleware = [];

 

   public function group(string $prefix, array $middleware, callable $callback): void
   {
       $previousPrefix = $this->currentPrefix;
       $previousMiddleware = $this->currentMiddleware;

       $this->currentPrefix .= $prefix;
       $this->currentMiddleware = array_merge($this->currentMiddleware, $middleware);

       $callback($this);

       $this->currentPrefix = $previousPrefix;
       $this->currentMiddleware = $previousMiddleware;
   }

   public function get(string $path, callable $handler): void
   {
       $fullPath = $this->currentPrefix . $path;
       $this->routes['GET'][] = new Route($fullPath, $handler, $this->currentMiddleware);
   }
}

$router->group('/admin', [AuthMiddleware::class], function (Router $router) {
   $router->get('/users', [UserController::class, 'index']);
   $router->get('/users/{id}', [UserController::class, 'show']);
});
```
Nesting groups (an admin group inside an API version group, for instance) works the same way, since the prefix and middleware simply accumulate through the callback chain before being restored afterward.

 

Step 4: Why Route Caching Becomes Necessary

The implementation above compiles a regex for every route on every request — fine for a handful of routes, but this becomes measurably slow once an application reaches hundreds of routes, since every request potentially runs through dozens of failed regex matches before finding (or failing to find) the right one.


The fix is to separate route compilation from route registration. Instead of building routes fresh on every request, route definitions are compiled once (typically during a deploy step) into a cached, optimized lookup structure — often a single combined regex with named capture groups per route, or a precomputed array — then loaded directly on each request without re-registering or re-compiling anything:

```php
class RouteCache
{
   public function build(array $routeDefinitions, string $cacheFile): void
   {
       $compiled = [];

 

       foreach ($routeDefinitions as $method => $routes) {
           foreach ($routes as $route) {
               $compiled[$method][] = [
                   'pattern' => $route->pattern(),
                   'paramNames' => $route->paramNames(),
                   'handler' => $route->handlerSignature(),
               ];
           }
       }

       file_put_contents($cacheFile, '   }

   public function load(string $cacheFile): array
   {
       return require $cacheFile;
   }
}
```
With this in place, a request no longer touches route registration code at all — it loads a pre-built array and runs straight to matching, which is the difference between routing being a negligible cost and routing showing up as a real line item in a performance profile at scale.

 

Where This Connects to the Rest of the Framework

Once a route matches, the handler it resolves is typically a controller method — and building that controller with all its dependencies is exactly the job of the dependency injection container built in our previous article. In a complete framework, route dispatch and container resolution work together: the router determines which controller and method to call, and the container determines how to actually construct that controller with everything it needs.

 

Frequently Asked Questions

Why not just use regular expressions directly instead of a router abstraction?
You can for a handful of routes, but the abstraction pays for itself once you need parameter extraction, groups, and middleware attached consistently — hand-rolling regex matching for every route individually reintroduces exactly the duplication a router is meant to eliminate.


At what point does route caching actually matter?
It depends on route count and request volume, but as a rough signal: if route registration involves looping over hundreds of route definitions and compiling regexes on every single request, that's measurable overhead worth eliminating, especially on high-traffic endpoints.


Do route parameters need type casting?
The raw match is always a string, since it comes from a URL. Whether {id} gets cast to an integer is typically handled either in the controller itself or via a route parameter type constraint layered on top of the matching shown here.


Need a routing layer — or a full framework — built to handle real production traffic? Get in touch.

Contact Pierre Miniggio →