Over the last 2 years, FastRoute has become the de facto standard PHP routing solution. It's now used in popular frameworks like Slim3 and lumen, and is the foundation of other advanced routing libraries.

The basic idea behind FastRoute is to combine routes regular expressions together in chunks to minimize preg_match() calls. At a first glance nikis's article "Fast request routing using regular expressions" makes a lots of sense. After a couple of hours of benchmarking, however, the regular expressions combination doesn't seem to live up to its promises.

The story began when I was looking for a routing library. FastRoute was obviously one of the front runners. However, FastRoute doesn't support advanced features like reverse routing or subdomain routing out of the box, and despite the large numbers of routing libraries available, I didn't really find the one which suited all my needs. So, I decided to create a new routing library following nikic's precepts.

Once done, I decided to do some benchmarking to evaluate the final outcome - but the results weren't as good I'd hoped. And that's where things became interesting...

Establishing a Benchmarking Process

Having seen that my implementation did not perform as well as FastRoute, I decided to benchmark other routers. Benchmarks for php-router-benchmark also seemed pretty poor compared to FastRoute. So, I started to investigate to understand why FastRoute was beating its competition.

I quickly realized that the benchmarking script wasn't really realistic. Moreover, it only benchmarks the actual routing step and not the route creation phase. So I decided to create a more elaborate benchmarking script to carry out my investigations.

So to have a larger overview, I decided to benchmark the three different situations:

  1. the best case (i.e when a request matches the first route for all HTTP methods)
  2. the worst case (i.e when a request matches the last route for all HTTP methods)
  3. the average case (i.e the mean which is probably the most realistic test).

There are, however, many different ways to configure routes. For example, in a controller/action strategy, each route generally matches a single HTTP method only. Conversely, in a REST approach, the same route's pattern can handle several HTTP methods. So, to take advantage of all different routing implementation's optimizations, I ran the benchmark using the following sets of routes:

  • in the first set all routes matches all HTTP methods.
  • in the second set all routes match only a single HTTP method.

Taken separately, none of these sets are very realistic, but it should allow us to triangulate a real world scenario.

I then picked up the following routing implementations to do the benchmarking:

The Results

Instead of copy pasting the whole benchmark results in this article, I'll just pick up the most interesting parts.

So the first bar chart is obtained with 100 routes using the route set number 1: router_benchmarks1.png

While the second bar chart is obtained with 100 routes using the route set number 2: router_benchmarks2.png

As you can see, performance really depends on the route set. The different performances obtained by FastRoute can be explained by how routes are stored internally. FastRoute indexes routes by HTTP method names. So when a PUT request is done, FastRoute only need to iterate over PUT routes. Whilst this optimization works well when routes matches only a single HTTP Verb, it doesn't help much when routes matches all HTTP Verbs. Furthermore, when FastRoute needs to process the whole routes collection the same way as Symfony does, Symfony reach better performances here.

Further Investigations

Many questions remains... How can a library using a classic routing strategy obtain better performances than FastRoute? Does FastRoute's performance has something to do with its "combined regular expression" strategy?

To answer these questions I decided to write a classic routing strategy for FastRoute. The created classic strategy simply works through a route's patterns one by one until one matches the request path. You can see the benchmark results below.

Note: below FastRoute* will stands for FastRoute using a classic routing strategy.

So, to focus on the combined regular expression optimization, the results below only benchmark the routing step (i.e we are simply ignoring routes creation here).

Results obtained with the route set number 1: fastroute1.png

Results obtained with the route set number 2: fastroute2.png

Fewer calls to preg_match() on combined regular expressions clearly shows better performance.

Routing in PHP is a process which is executed once per request and the routes creation can't be ignored any more, so below you can find benchmark results, which also include the routes creation step.

Results obtained with the route set number 1: fastroute3.png

Results obtained with the route set number 2: fastroute4.png

So in this last benchmark results, the conclusion is pretty unambiguous: the time saved by using combined regular expression doesn't really compensate for the extra complexity added by all intermediate introduced structures to make the approach faster.

Conclusion

The fact that FastRoute's strategy doesn't bring any additional performance to a classic routing strategy has at least one positive point: route instances can be responsible for their own matching logic, which simplifies greatly the codebase.

Trying to increase routing library performance is fine, but it's not the only way to solve a routing performance issue. There are, for example, libraries like li3_resources, which minimize route creation by a simple design abstraction. Indeed, in li3_resources, HTTP methods management has been delegated up to the Resource instance, so instead of creating one route per supported HTTP method, only one route accepting all HTTP methods is created and then it's up to the Resource instance to respond or not to it. This kind of separation of responsibility can also be used with a classic controller/action strategy. A single '/{controller}/{action}[/{args}]' pattern coupled with a class_exists() and a method_exists() can supports an infinite number of requests by defining only one route.

So, there are a lot of possibilities to explore in routing design - it seems there is room for improvement in clarity, expressiveness and performance.