stefanov1989 / ariel-radix-router
A fast, dependency-free radix-tree HTTP router for PHP 8.4 with middleware, route groups and compiled caches.
Package info
github.com/StefanoV1989/ariel-radix-router
pkg:composer/stefanov1989/ariel-radix-router
Requires
- php: >=8.4
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.0
- squizlabs/php_codesniffer: ^4.0.1
This package is auto-updated.
Last update: 2026-07-20 07:30:31 UTC
README
Ariel is a fast, dependency-free HTTP router for PHP 8.4+. It uses a radix tree instead of scanning every route, keeps static lookups independent of route position, and includes middleware lifecycle safety, route groups, constraints, named URLs, persistent-worker support, and compiled indexes.
It is a standalone library: no framework, container, or HTTP implementation is required.
Why Ariel
- Radix-tree matching with static segments taking precedence over parameters.
- Constant-depth lookup: adding routes does not turn dispatch into a linear scan.
- Specialized fast paths for common constraints such as integers and alphanumeric IDs.
- Safe middleware lifecycles for classic PHP requests and long-running workers.
- Optional file-backed compiled indexes designed to benefit from OPcache.
- Explicit
404/405distinction and configurable exception rendering. - Route groups, optional parameters, regex fallbacks, named URLs, and class handlers.
- Laravel-like static facade plus an isolated, mockable instance API.
- Zero runtime dependencies and strict PHP 8.4 types.
- PHPUnit coverage, PHPStan level max, reproducible benchmarks, and CI on every change.
Installation
composer require stefanov1989/ariel-radix-router
Quick start
<?php declare(strict_types=1); use StefanoV1989\ArielRouter\Http\Request; use StefanoV1989\ArielRouter\Router; require __DIR__ . '/vendor/autoload.php'; Router::get('/', static fn (): string => 'Hello from Ariel'); Router::get('/users/{id}', static function (string $id): string { return 'User ' . $id; })->where('id', '[0-9]+')->name('users.show'); Router::post('/users', [UserController::class, 'store']); Router::error(static function (Request $request, Throwable $error): string { http_response_code($error->getCode() >= 400 ? $error->getCode() : 500); return json_encode(['error' => $error->getMessage()], JSON_THROW_ON_ERROR); }); Router::start();
For tests and workers, pass the request explicitly:
$result = Router::dispatch(new Request('GET', '/users/42'));
Static facade and mockable instances
The static Router facade remains the primary, shortest API and is suitable for
normal FPM applications and Ariel. Applications that prefer dependency
injection can instantiate ArielRouter instead. Every instance owns a separate
engine, route catalog, group stack, and request context.
use StefanoV1989\ArielRouter\ArielRouter; use StefanoV1989\ArielRouter\Contracts\RouterInterface; use StefanoV1989\ArielRouter\Http\Request; $router = new ArielRouter(); $router->add('GET', '/health', static fn (): string => 'ok'); $router->get('/users/{id}', [UserController::class, 'show']) ->where('id', '[0-9]+'); $router->group(['prefix' => '/api'], static function (RouterInterface $routes): void { $routes->post('/users', [UserController::class, 'store']); }); $result = $router->dispatch(new Request('GET', '/users/42'));
Both APIs use the same RouterEngine, matching logic, middleware lifecycle,
compiled catalogs, and persistent-worker cleanup. The object API therefore
works in FPM and Workerman without a separate adapter or different route
semantics.
Application services can depend on RouterInterface rather than the concrete
or static router:
use StefanoV1989\ArielRouter\Contracts\RouterInterface; use StefanoV1989\ArielRouter\Http\Request; final class RequestDispatcher { public function __construct(private RouterInterface $router) { } public function dispatch(Request $request): mixed { return $this->router->dispatch($request); } }
This contract can be replaced by a hand-written fake or a PHPUnit mock:
$router = $this->createMock(RouterInterface::class); $router->method('dispatch')->willReturn('mocked response'); $service = new RequestDispatcher($router);
Static calls are not themselves mocked; use RouterInterface at boundaries
where test substitution is useful. There is no requirement to replace existing
Router::get() or Router::dispatch() usage.
Routes
All standard methods are available, plus match(), any(), and the all() compatibility alias.
Router::get('/articles', $handler); Router::post('/articles', $handler); Router::put('/articles/{id}', $handler); Router::patch('/articles/{id}', $handler); Router::delete('/articles/{id}', $handler); Router::options('/articles', $handler); Router::head('/articles', $handler); Router::match(['GET', 'HEAD'], '/feed', $handler); Router::any('/health', $handler);
Handlers may be closures, invokable objects, function names, [Controller::class, 'method'], or Controller::class . '::method'. Non-static controller methods are instantiated without a container.
Parameters and constraints
Router::get('/posts/{slug}', $handler); Router::get('/archive/{year?}', $handler); Router::get('/users/{id}', $handler) ->where('id', '[0-9]+'); Router::get('/reports/{year}/{month}', $handler) ->where([ 'year' => '[0-9]{4}', 'month' => '0[1-9]|1[0-2]', ]);
For overlapping dynamic routes, more specific known constraints win. Static segments always win over dynamic segments. Exact collisions are rejected during compilation instead of silently overwriting a route.
URL parameters originate as strings. Class handlers may declare scalar PHP
types (int, float, string, or bool): dispatch applies PHP's native
scalar coercion rules without runtime metadata inspection. Invalid values still
raise TypeError; use where() to reject them during route matching.
Use regex() only for legacy patterns that cannot be represented as path segments. Regex routes are checked after the radix index:
Router::get('/legacy', $handler)->regex('~^/legacy/(\d+)$~');
Groups
Groups nest and compose prefixes, namespaces, and middleware in declaration order.
Router::group([ 'prefix' => '/api', 'middleware' => [ RequestIdMiddleware::class, CorsMiddleware::class, ], ], static function (): void { Router::group(['prefix' => '/v1'], static function (): void { Router::get('/users/{id}', [UserController::class, 'show']); }); });
The middleware group option accepts either one middleware or an ordered array. Nested-group middleware is cumulative: outer-group middleware runs first, followed by inner-group middleware and then route middleware.
Named URLs
Router::get('/users/{user}/files/{file?}', $handler)->name('files.show'); $url = Router::url('files.show', [ 'user' => 'jane doe', 'file' => 'report/1', ], [ 'download' => 1, ]); // /users/jane%20doe/files/report%2F1?download=1
Missing named routes throw an InvalidArgumentException; malformed links do not fail silently.
Middleware
A middleware implements one of the explicit lifecycle contracts. A class name creates a fresh instance for each dispatch:
use StefanoV1989\ArielRouter\Contracts\Middleware; use StefanoV1989\ArielRouter\Http\Request; final class AuthMiddleware implements Middleware { public function handle(Request $request): void { if ($request->header('authorization') === null) { throw new RuntimeException('Unauthenticated', 401); } } } Router::get('/profile', $handler)->middleware(AuthMiddleware::class);
Middleware calls are cumulative. Every call appends one middleware and preserves insertion order:
Router::get('/admin/users', $handler) ->addMiddleware(AuthMiddleware::class) ->addMiddleware(AdminMiddleware::class) ->addMiddleware(AuditMiddleware::class);
In this example execution order is AuthMiddleware, AdminMiddleware, AuditMiddleware, then the route handler. Group middleware is placed before route middleware. Terminable middleware runs after the handler in reverse order.
middleware() and addMiddleware() are exact aliases and both are cumulative. middleware() offers the concise public API, while addMiddleware() keeps declarations explicit and makes migration from existing applications straightforward. They may be mixed without changing ordering:
Router::get('/profile', $handler) ->middleware(AuthMiddleware::class) ->addMiddleware(AuditMiddleware::class);
Object instances must make their lifecycle explicit:
StatelessMiddlewaremay be safely shared.RequestCloneableMiddlewarereturns a request-scoped copy.MiddlewareFactorycreates an instance per request and supports dependency injection.TerminableMiddlewarerunsterminate()after the handler, in reverse order.
This prevents request state from leaking when the same router lives inside RoadRunner, FrankenPHP, Swoole, or Workerman.
Requests and errors
Request::fromGlobals() is used by Router::start(). The constructor makes testing and server adapters straightforward:
$request = new Request( method: 'PATCH', uri: '/users/42?notify=1', headers: ['Authorization' => 'Bearer token'], ); $request->method(); // patch $request->url()->path(); // /users/42 $request->url()->queryParam('notify'); $request->header('authorization');
Unmatched paths throw HttpException with code 404; known paths with the wrong method use 405. Register Router::error() to convert any Throwable into your application's response format.
Router information helpers
The facade exposes read-only helpers for debug panels, route listing commands, tests, health checks, and framework integrations:
Router::routes(); // list<Route> Router::routeCount(); // int Router::namedRoute('users.show'); // Route|null Router::hasNamedRoute('users.show'); // bool $match = Router::resolve('GET', '/users/42'); $match->route; // Route|null $match->parameters; // ['42'] $match->methodNotAllowed; // bool Router::hasRoute('GET', '/users/42'); // bool Router::isCompiled(); // bool Router::compilationCount(); // int Router::currentRequest(); // Request|null Router::currentRoute(); // Route|null
resolve() and hasRoute() match the radix index without executing the handler or middleware. The first call may lazily compile and freeze the route catalog, exactly like the first dispatch.
currentRequest() and currentRoute() return values only while a dispatch is active; outside dispatch they return null. Use request() when application code intentionally wants the active request or a request created from PHP globals.
Each returned Route exposes its path, methods, name, handler, middleware, constraints, parameter names, namespace, and regex through its typed accessors:
$route = Router::namedRoute('users.show'); $route?->path(); $route?->methods(); $route?->middlewares(); $route?->conditions(); $route?->parameterNames(); $definition = $route?->definition(); // immutable RouteDefinition|null $definition?->path; $definition?->methods;
RouteDefinition is a typed, readonly snapshot of a route. It replaces large
internal array shapes when compiled catalogs are loaded and composed. The
exported payload returned by compiledPayload() remains an array so it can be
stored efficiently with var_export() and stays compatible with existing
catalog files.
Compilation and production cache
The in-memory index compiles lazily on the first dispatch and is reused afterwards. Route definitions become immutable after compilation, catching accidental production mutations.
To store the compact radix topology on disk:
use StefanoV1989\ArielRouter\IndexMode; use StefanoV1989\ArielRouter\Router; $engine = Router::configure(__DIR__ . '/storage/cache/router'); $engine->setIndexMode(IndexMode::File);
Use a writable deployment cache directory and enable OPcache. Writes are atomic. Closures and runtime middleware objects remain valid for the normal index, but exportable compiled definition catalogs require class/function names or [class, method] handlers.
For build-time route catalogs:
$payload = Router::compiledPayload(); file_put_contents($file, '<?php return ' . var_export($payload, true) . ';'); // During application bootstrap: Router::appendCompiledDefinitions('api-v1', require $file);
Persistent workers
Create one engine, register routes once, and dispatch a new Request for every job. Ariel releases its active request and route in a finally block, including exceptional paths.
$engine = Router::configure(); require __DIR__ . '/routes.php'; Router::compile(); while ($request = $server->nextRequest()) { $result = Router::dispatch($request); $server->respond($result); }
The same lifecycle is available without static state:
$router = new ArielRouter(); require __DIR__ . '/routes-instance.php'; $router->compile(); while ($request = $server->nextRequest()) { $server->respond($router->dispatch($request)); }
Do not share arbitrary middleware objects. Use the lifecycle contracts documented above.
Benchmarks
Representative local result (Apple M1 Pro, 16 GiB, macOS arm64, PHP 8.4.1, CLI OPcache disabled):
| Workload | Throughput | Latency |
|---|---|---|
| Static route, first | 632,654 ops/s | 1,581 ns/op |
| Static route, middle | 633,733 ops/s | 1,578 ns/op |
| Static route, last | 632,447 ops/s | 1,581 ns/op |
| Dynamic constrained route, last | 471,658 ops/s | 2,120 ns/op |
The fixture contains 2,000 routes (1,000 static and 1,000 constrained dynamic routes), runs 5,000 warm-up dispatches per case, then measures 100,000 complete Router::dispatch() calls. Median registration took 2.445 ms, median compilation 3.594 ms, and peak memory was 8 MiB across five runs.
A separate parity run with 4,000 routes compared the static facade with a
dedicated ArielRouter instance. Median results were 640,528 versus 656,398
ops/s for static dispatch and 516,926 versus 523,420 ops/s for constrained
dynamic dispatch; both used 12 MiB peak memory. The purpose of this comparison
is to verify that instance isolation and mockability add no dispatch penalty,
not to claim that one calling style is inherently faster.
These are internal microbenchmark results, not production capacity promises. Hardware, PHP builds, extensions, handler work, and middleware affect results. The important invariant is visible in the first/middle/last static cases: lookup cost does not grow with declaration position. Measure the complete application on its deployment target before making capacity decisions.
Quality checks
composer check # PSR-12, PHPUnit, then PHPStan level max composer style # PSR-12 compliance composer test composer stan composer validate --strict
The CI workflow validates Composer metadata, enforces PSR-12, tests the supported PHP baseline, and runs PHPStan at level max without a baseline or ignored errors.
Versioning and maintenance
Ariel follows Semantic Versioning. Public APIs remain compatible within a major release; deprecations are documented before removal. See CHANGELOG.md, CONTRIBUTING.md, and SECURITY.md.
License
MIT. See LICENSE.