Building the fastest PHP router ever

Ok, so the title might be a little bit click-baity since I haven’t compared it to every router in existence. Just nikic’s Fast Route.

On my sabbatical last summer, I was messing about with microservices (which I’m convinced are evil, but that is an article for another day). During that, I decided to create a dead-simple router. This router is based on the learnings you’ve maybe read here: how to abuse and use PHP arrays for speed. Go check it out if you haven’t already.

Anyway, I recently started on a project that I can’t wait to tell you all about, but alas, I used my dead-simple router. It wasn’t until I benchmarked it that I realized it was … um, faster than I thought.

+------+------------------+----------------------------------+-----+------+------------+-----------+--------------+----------------+
| iter | benchmark        | subject                          | set | revs | mem_peak   | time_avg  | comp_z_value | comp_deviation |
+------+------------------+----------------------------------+-----+------+------------+-----------+--------------+----------------+
| 0    | RouterBenchmarks | benchBuildingBlocksSimpleMatch   |     | 1000 | 1,155,328b | 1.804μs   | +0.00σ       | +0.00%         |
| 0    | RouterBenchmarks | benchBuildingBlocksWithVar       |     | 1000 | 1,156,360b | 2.655μs   | +0.00σ       | +0.00%         |
| 0    | RouterBenchmarks | benchBuildingBlocksHundredRoutes |     | 1000 | 1,355,608b | 165.628μs | +0.00σ       | +0.00%         |
| 0    | RouterBenchmarks | benchFastRouteHundredRoutes      |     | 1000 | 1,228,744b | 104.864μs | +0.00σ       | +0.00%         |
| 0    | RouterBenchmarks | benchFastRouteWithVar            |     | 1000 | 1,181,736b | 5.153μs   | +0.00σ       | +0.00%         |
| 0    | RouterBenchmarks | benchFastRouteSimpleMatch        |     | 1000 | 1,177,960b | 2.193μs   | +0.00σ       | +0.00%         |
+------+------------------+----------------------------------+-----+------+------------+-----------+--------------+----------------+

And thus, I decided to share it with the world and tell you how it works.

Using the Router

Before you can initialize it, you have to install it.

composer require withinboredom/building-blocks

It’s part of my grab-bag “building blocks” repo that has some random stuff I’ve made. From there, it’s a pretty simple affair:

<?php

$router = new Router($_SERVER['REQUEST_METHOD'], parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));

$router->registerRoute(
        'GET', '/hello/:name', static function (string $name): Result {
        return new Result(HttpResponseCode::Ok, "hello $name");
    }
);

$result = $router->doRouting();

$result->emit();

And that’s it. Compared to FastRoute, it’s pretty darn simple. There are some downsides, but let’s see how it works first.

How it works

When you first initialize the router, we map over the array and normalize the requested URL. This basically just URL decodes the request and trims each path part. Thus /my%20/////path === /my/path.

We store that in an array: ['GET', ['my','path']]

Then, when you register a route, we split the path again, so to register /my/:id we start with ['my', ':id'].

We then create an array based on the current request: ['my' => 'my', ':id' => 'path'] and create a callback to generate the actual call to the handler and store it like ['GET', ['my', 'path'], $callback] by replacing the current variable values with the state of the request.

When we call doRouting, we iterate over the generated handlers for an exact match, and the first one we find, we call the callback.

The callback then takes the calculated parameters and passes them to the user-defined handler.

That’s literally it.

What about PSR compatibility?

I personally feel that PSR’s have resulted in bloat up and down the stack. Yes, they’re handy when composing packages, and even making this 100% PSR compatible is not at all complicated if you are so inclined.

Downsides

FastRoute can cache registered routes, while this algorithm cannot. In practice, this really only matters when you have hundreds of routes.

You’ll always get a string for every path parameter.

The first route to match will always win so you need to take some care in the order you register routes. Sure, the software can solve this, but this is an optimization the developer (or tooling) should be making, and not calculated during runtime.

Optimizations

I can optimize this router further, and I’d like to, eventually. There are a number of optimizations I’d like to make:

  1. 404’ing faster: Currently we have to check every … single … route for a possible match, including ones that make no sense. For example, there’s no reason to try testing a GET request to a POST route.
  2. We calculate parameters twice. First to create the match, and second to call the route’s callback. Ideally, we’d only do this once.