Writing a PHP 8.5 Router Faster Than FastRoute

When working on Safi, my custom minimal PHP microframework, I initially reached for nikic/fast-route. It’s a battle-tested library that defined modern PHP routing, and I’ve always admired Nikita Popov’s classic write-up on regex routing performance.

However, since Safi focuses on zero-overhead execution, I started wondering: Can I build a dedicated router for PHP 8.5+ that runs even faster?

What followed was an intense engineering evolution—from a failed data-structure experiment to deep micro-benchmarking, and ultimately to Wajha (chani/wajha).

Here is how Wajha evolved, the micro-benchmarks that shaped its design, an honest breakdown of where its speed comes from, and its performance across both synthetic and real-world benchmarks.


A Note on the Name

Where does Wajha come from? It’s Moroccan Darija (واجهة / وجهة), carrying a double meaning that fits an HTTP router perfectly: it translates to both “interface” (or front facade) and “destination” (or direction). Given that a router acts as both the front door for incoming traffic and the mechanism that guides requests to their destination, the name felt like a perfect fit.


1. The Engineering Evolution

Attempt 1: The Radix Tree (Failed)

My first attempt was to prefix dynamic routes using a Radix tree (trie) before passing them to regex evaluation. That experiment failed. The additional object allocations, stack frames, and array traversal overhead in PHP made dispatching significantly slower than pure chunked regular expressions.

Attempt 2: Synthetic Chunking

Going back to the drawing board led to a hybrid architecture: an O(1) static hash map combined with first-character bucketed PCRE2 regex chunks. In initial synthetic benchmarks (1,000 randomized routes), Wajha achieved ~447,000 req/s—roughly 3.47x faster than FastRoute.

Attempt 3: The Paranoia Phase & Real-World Realities

Synthetic benchmarks with randomized URIs can be misleading because random words distribute uniformly across alphabet buckets. Real web applications don’t look like that—80% of routes share prefixes like /api//admin/, or /shop/.

To ensure Wajha wasn’t just a “benchmarketing” trick, I audited it against two strict reference suites:

  • FastRoute’s Unit Test Suite (DispatcherTestCase): 100% compliance verified across 24 test cases and 61 assertions (including RFC 9110 HEAD fallbacks and HTTP 405 Allow header generation).
  • FastRoute’s Real-World Benchmark Dataset: A dataset of ~40 real-world e-commerce, blog, and admin routes.

When testing Wajha against FastRoute’s real-world dataset, the initial results were a reality check: while Wajha dominated in 404/405 handling and static lookups, FastRoute was initially ~30% faster on dynamic routes in small datasets.


2. Micro-Benchmarking the Zend VM

To bridge that gap, I built isolated micro-benchmark suites running 500,000 iterations against individual PHP 8.5 VM and PCRE2 operations. This revealed crucial insights about memory allocation and C-boundary jumps.

P.S: The following section is dedicated to a former boss who never hesitated to tell me that optimizing code for speed is useless because “the compiler should already know how to do this best”—and that if you have to optimize manually, it’s just a bad programming language.

What Worked (Huge Wins):

  • Compile-Time Bucket Merging (+137% speedup): Eliminating runtime array_merge() calls on wildcard routes. Pre-merging wildcard chunks during compilation avoided heap memory allocation on every request.
  • Removing Hot-Path strtoupper() (+110% speedup): Forcing uppercase HTTP methods as an interface contract avoided allocating new string zvals in memory during dispatch.
  • Direct Array Lists over array_keys() (+153% speedup): Generating sequential arrays directly instead of converting associative hash maps via array_keys().
  • Inlining VM Call Frames (+33% speedup): Inlining dynamic matching directly into dispatch() saved VM call frame pushes/pops on the stack.
  • String MARK Keys (+21.8% speedup): Storing MARK keys as strings ('0''1') in the compiled map avoided runtime (int) type casting from PCRE2 match arrays.
  • match() Unrolled Variable Extraction (+12% speedup): Replacing generic foreach loops with explicit match ($varCount) assignments for 1, 2, or 3 variables (covering ~95% of dynamic routes).

What Failed (Busted Myths):

  • Flat String Map Keys (GET:/path vs 2D Arrays): Tested 37% slower. String concatenation (.) allocates new heap memory per request. Nested 2D array lookups ($map[$method][$uri]) reuse existing hash table pointers and are much faster in PHP.
  • Positional Captures ((?|...)) vs Named Groups ((?<var>...)): Tested 4.7% slower. The branch-reset operator increases PCRE2 state-machine complexity, and the subsequent PHP index-loop offset negated any gain over C-level named extractions with (?J).
  • PREG_UNMATCHED_AS_NULL: Tested 6% slower because PCRE2 explicitly allocates null zvals for non-matching optional groups rather than omitting them.

3. The Mechanics Behind Wajha’s Architecture

Combining these micro-optimization findings resulted in Wajha’s current architecture:

1. O(1) Static Lookup Fast-Path

Static routes bypass regex evaluation entirely. Wajha maintains a two-dimensional lookup table keyed by HTTP method and URI ($staticRoutes[$method][$uri]).

if (isset($this->staticRoutes[$method][$uri])) {
    return [self::FOUND, $this->staticRoutes[$method][$uri], []];
}

2. First-Character Path Bucketing with Pre-Merged Wildcards

Dynamic routes are partitioned by the second byte of the URI ($uri[1]). For a request to /users/42, Wajha only evaluates regex chunks bucketed under 'u'. Wildcard routes (/{id}) are merged into character buckets at compile time, eliminating runtime array operations.

3. PCRE2 (?J) Named Capture Extractions & String MARKs

Dynamic routes are compiled into 30-route chunks using PCRE2-native duplicate named groups (?J) and (*MARK:N) identifiers. PCRE2 handles variable name mapping at the C level, writing the matched index directly into $matches['MARK'].

4. Zero-Stack & Unrolled Variable Assignment

Dispatching operates completely flat. Variable extraction bypasses foreach loops for standard route signatures:

$varCount = count($routeData['vars']);
$vars = match ($varCount) {
    1 => [$routeData['vars'][0] => $matches[$routeData['vars'][0]]],
    2 => [
        $routeData['vars'][0] => $matches[$routeData['vars'][0]],
        $routeData['vars'][1] => $matches[$routeData['vars'][1]],
    ],
    default => $this->extractVarsLoop($routeData['vars'], $matches),
};

4. Benchmark Results

Benchmark 1: Large Application Dataset (1,000 Routes)

Evaluated on PHP 8.5 over 100,000 iterations against a dataset of 1,000 routes (70% valid, 15% 405 method mismatches, 15% 404 paths):

EngineThroughputAvg LatencySpeed Ratio
Safi/Wajha447,171 req/s2.236 µsBaseline (1.00x)
nikic/FastRoute129,849 req/s7.701 µs3.29x slower
Phroute106,149 req/s9.421 µs4.02x slower
Symfony Routing110,052 req/s9.087 µs3.88x slower
AltoRouter4,334 req/s230.711 µs98.50x slower

In large route tables, Wajha’s first-character bucketing keeps PCRE2 search trees small, delivering over 3.2x higher throughput than FastRoute.

Benchmark 2: Real-World Dataset (~40 Routes)

Evaluated on FastRoute’s official reference real-world route collection (100,000 iterations per scenario):

ScenarioWajhaFastRouteSpeed Ratio
Static First Route (/)3,560,862 ops/s3,402,148 ops/s1.05x faster
Static Last Route (/admin/category)3,395,455 ops/s3,216,146 ops/s1.06x faster
Dynamic First Route (/page/hello)828,027 ops/s919,533 ops/s0.90x (0.10x slower)
Dynamic Last Route (/admin/category/123)739,968 ops/s803,174 ops/s0.92x (0.08x slower)
HTTP 405 Method Not Allowed511,156 ops/s385,532 ops/s1.33x faster
HTTP 404 Unknown Route648,046 ops/s400,450 ops/s1.62x faster

The Trade-off

In small datasets (~40 routes), FastRoute holds a ~8–10% edge on pure dynamic matching because it extracts variables via positional array indices ($matches[1]) rather than named string keys. Wajha intentionally accepts this minor trade-off in small datasets to provide named variable dictionaries natively while outperforming FastRoute on static routes, 404/405 error handling, and scaling massively in large applications.


5. Quick Start & Usage

Wajha cleanly separates route compilation (WajhaCompiler) from runtime execution (WajhaDispatcher).

Installation

composer require chani/wajha

Usage Example

<?php

use Safi\Wajha\WajhaCompiler;
use Safi\Wajha\WajhaDispatcher;

$compiler = new WajhaCompiler();

// Standard HTTP routes
$compiler->get('/users', 'UserController@index');
$compiler->post('/users', 'UserController@store');

// Type shorthands (:int, :uuid, :slug, :alpha)
$compiler->get('/users/{id:int}', 'UserController@show');

// Backed Enums (expands to regex alternations at compile time)
$compiler->get('/orders/{status:' . OrderStatus::class . '}', 'OrderController@index');

// Route Groups
$compiler->addGroup('/api/v1', function (WajhaCompiler $api) {
    $api->get('/products', 'ProductController@index');
});

// Compile & Dispatch
$dispatcher = new WajhaDispatcher($compiler->compile());

// Method MUST be uppercase (contract)
$result = $dispatcher->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);

switch ($result[0]) {
    case WajhaDispatcher::FOUND:
        [$handler, $vars] = [$result[1], $result[2]];
        break;

    case WajhaDispatcher::METHOD_NOT_ALLOWED:
        $allowedMethods = $result[1]; // Compliant Allow header array
        break;

    case WajhaDispatcher::NOT_FOUND:
        http_response_code(404);
        break;
}

Repository & Links

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.