stefanov1989/ariel-radix-router

A fast, dependency-free radix-tree HTTP router for PHP 8.4 with middleware, route groups and compiled caches.

Maintainers

Package info

github.com/StefanoV1989/ariel-radix-router

pkg:composer/stefanov1989/ariel-radix-router

Transparency log

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v1.0.1 2026-07-19 11:11 UTC

This package is auto-updated.

Last update: 2026-07-19 11:12:03 UTC


README

CI Latest Stable Version License PHP Version

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/405 distinction and configurable exception rendering.
  • Route groups, optional parameters, regex fallbacks, named URLs, and class handlers.
  • 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

Until the first Packagist release, use the repository as a Composer VCS repository or a local path repository.

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'));

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:

  • StatelessMiddleware may be safely shared.
  • RequestCloneableMiddleware returns a request-scoped copy.
  • MiddlewareFactory creates an instance per request and supports dependency injection.
  • TerminableMiddleware runs terminate() 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();

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);
}

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 624,323 ops/s 1,602 ns/op
Static route, middle 615,351 ops/s 1,625 ns/op
Static route, last 617,759 ops/s 1,619 ns/op
Dynamic constrained route, last 455,981 ops/s 2,193 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. Registration took 2.535 ms, compilation 3.579 ms, and peak memory was 8 MiB in this run.

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       # PHPUnit, then PHPStan level max
composer test
composer stan
composer validate --strict

The CI workflow validates Composer metadata, 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.