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 9110HEADfallbacks and HTTP 405Allowheader 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 viaarray_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
MARKkeys as strings ('0','1') in the compiled map avoided runtime(int)type casting from PCRE2 match arrays. match()Unrolled Variable Extraction (+12% speedup): Replacing genericforeachloops with explicitmatch ($varCount)assignments for 1, 2, or 3 variables (covering ~95% of dynamic routes).
What Failed (Busted Myths):
- Flat String Map Keys (
GET:/pathvs 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 allocatesnullzvals 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):
| Engine | Throughput | Avg Latency | Speed Ratio |
|---|---|---|---|
| Safi/Wajha | 447,171 req/s | 2.236 µs | Baseline (1.00x) |
| nikic/FastRoute | 129,849 req/s | 7.701 µs | 3.29x slower |
| Phroute | 106,149 req/s | 9.421 µs | 4.02x slower |
| Symfony Routing | 110,052 req/s | 9.087 µs | 3.88x slower |
| AltoRouter | 4,334 req/s | 230.711 µs | 98.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):
| Scenario | Wajha | FastRoute | Speed Ratio |
|---|---|---|---|
| Static First Route (/) | 3,560,862 ops/s | 3,402,148 ops/s | 1.05x faster |
| Static Last Route (/admin/category) | 3,395,455 ops/s | 3,216,146 ops/s | 1.06x faster |
| Dynamic First Route (/page/hello) | 828,027 ops/s | 919,533 ops/s | 0.90x (0.10x slower) |
| Dynamic Last Route (/admin/category/123) | 739,968 ops/s | 803,174 ops/s | 0.92x (0.08x slower) |
| HTTP 405 Method Not Allowed | 511,156 ops/s | 385,532 ops/s | 1.33x faster |
| HTTP 404 Unknown Route | 648,046 ops/s | 400,450 ops/s | 1.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/wajhaUsage 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;
}