phpdot/routing

High-performance segment-trie routing for PHP. PSR-7/15/17 compliant.

Maintainers

Package info

github.com/phpdot/routing

pkg:composer/phpdot/routing

Statistics

Installs: 19

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.3.1 2026-04-29 10:30 UTC

This package is auto-updated.

Last update: 2026-04-29 10:32:01 UTC


README

High-performance segment-trie router for PHP. PSR-7/15/17 compliant.

Install

composer require phpdot/routing

Quick Start

use PHPdot\Routing\Router;

$router = new Router($container, $responseFactory);

$router->get('/users', [UserController::class, 'index']);
$router->get('/users/{id:int}', [UserController::class, 'show']);
$router->post('/users', [UserController::class, 'store']);

$response = $router->handle($request);

One class. Three lines of route registration. One line of dispatch.

Inside the phpdot framework

Router carries #[Singleton], so when used with phpdot/package it's auto-wired by the container — no manual new Router(...) needed. Both constructor dependencies (Psr\Container\ContainerInterface and Psr\Http\Message\ResponseFactoryInterface) are resolved automatically (the latter via phpdot/http's default binding).

$container = (new ContainerBuilder())
    ->addDefinitionsFromFile(vendor('phpdot/definitions.php'))
    ->build();

$router = $container->get(Router::class);
$router->get('/', [HomeController::class, 'index']);
$response = $router->handle($request);

The #[Singleton] attribute is inert at runtime — phpdot/container is a require-dev only. Standalone consumers (no DI framework) instantiate Router directly via the constructor as shown above.

Architecture

Request/Response Lifecycle

                          BOOT (once per worker)
 ┌─────────────────────────────────────────────────────────────┐
 │                                                             │
 │   Register routes (fluent API)                              │
 │      $router->get('/users/{id:int}', ...)                   │
 │      $router->group('/api', function () { ... })            │
 │                        │                                    │
 │                        ▼                                    │
 │              RouteCollection                                │
 │           (flat list of Route objects)                       │
 │                        │                                    │
 │                        ▼                                    │
 │             RouteCompiler::compile()                         │
 │                        │                                    │
 │                        ▼                                    │
 │                   TrieNode tree                              │
 │            (indexed by path segments)                        │
 │                        │                                    │
 │                        ▼                                    │
 │                   TrieMatcher                                │
 │              (ready to match requests)                       │
 │                                                             │
 └─────────────────────────────────────────────────────────────┘

                      REQUEST (per coroutine)
 ┌─────────────────────────────────────────────────────────────┐
 │                                                             │
 │   ServerRequestInterface                                    │
 │      GET /users/42                                          │
 │                        │                                    │
 │                        ▼                                    │
 │              Router::handle($request)                        │
 │                        │                                    │
 │                        ▼                                    │
 │              Path::segments('/users/42')                     │
 │              → ['users', '42']                               │
 │                        │                                    │
 │                        ▼                                    │
 │           TrieMatcher::match('GET', segments)                │
 │                        │                                    │
 │              ┌─────────┼──────────┐                         │
 │              ▼         ▼          ▼                         │
 │          RouteMatch  405       null                         │
 │              │     MethodNot   (not found)                   │
 │              │     Allowed        │                         │
 │              │         │          ▼                         │
 │              │         │     fallback()                      │
 │              │         │     or 404                          │
 │              │         ▼                                    │
 │              │    Response 405                               │
 │              │    + Allow header                             │
 │              ▼                                              │
 │     Middleware Pipeline                                      │
 │              │                                              │
 │     ┌────────┼────────┐                                     │
 │     ▼        ▼        ▼                                     │
 │   MW 1 → MW 2 → ... → Route Handler                        │
 │     │        │        │       │                             │
 │     │        │        │       ▼                             │
 │     │        │        │   Controller::show($request, 42)    │
 │     │        │        │       │                             │
 │     ◄────────◄────────◄───────┘                             │
 │              │                                              │
 │              ▼                                              │
 │       ResponseInterface                                     │
 │                                                             │
 └─────────────────────────────────────────────────────────────┘

Trie Matching

Routes are compiled into a segment trie at boot. Matching walks one node per URL segment — O(depth), not O(routes).

Routes:
  GET  /users
  GET  /users/{id:int}
  GET  /users/{id:int}/posts
  POST /users/{id:int}/posts
  GET  /api/v1/health
  GET  /docs/{path:*}

Trie:
  root
  ├── "users" ────────────────── [GET → Route#1]
  │   └── {id:int} ──────────── [GET → Route#2]
  │       └── "posts" ────────── [GET → Route#3, POST → Route#4]
  │
  ├── "api"
  │   └── "v1"
  │       └── "health" ────────── [GET → Route#5]
  │
  └── "docs"
      └── {path:*} ────────────── [GET → Route#6]

Matching GET /users/42/posts:

Step 1: root → "users"   (hash lookup)
Step 2: "users" → "42"   (regex: [0-9]+ matches)
Step 3: {id} → "posts"   (hash lookup)
Step 4: leaf has GET?     → Route#3, params: {id: 42}

3 lookups. Same speed whether you have 10 routes or 1,000.

Middleware Pipeline (PSR-15)

Middleware wraps the handler inside-out. Each middleware can modify the request before and the response after.

Request
  │
  ▼
┌──────────────────────────────────────┐
│ Middleware 1                         │
│   before: log request                │
│   ┌──────────────────────────────┐   │
│   │ Middleware 2                 │   │
│   │   before: check auth         │   │
│   │   ┌──────────────────────┐   │   │
│   │   │ Route Handler        │   │   │
│   │   │   return Response    │   │   │
│   │   └──────────────────────┘   │   │
│   │   after: add CORS headers    │   │
│   └──────────────────────────────┘   │
│   after: log response                │
└──────────────────────────────────────┘
  │
  ▼
Response

Middleware can short-circuit by returning a response without calling $handler->handle().

Package Structure

src/
├── Router.php                      Main entry point
│
├── Route/
│   ├── Route.php                   Immutable route definition
│   ├── RouteCollection.php         Flat list of all routes
│   ├── RouteGroup.php              Fluent group builder
│   └── RouteScope.php              Reusable preset bundle
│
├── Compiler/
│   ├── RouteCompiler.php           RouteCollection → TrieNode tree
│   └── PatternRegistry.php         Named regex patterns
│
├── Matcher/
│   ├── MatcherInterface.php        Contract for matchers
│   ├── TrieMatcher.php             Walks compiled trie
│   ├── TrieNode.php                Trie node structure
│   ├── RouteMatch.php              Successful match result
│   └── MethodNotAllowed.php        405 result with allowed methods
│
├── Generator/
│   └── UrlGenerator.php            Named route → URL reversal
│
├── Contracts/
│   ├── ControllerInterface.php     Marker for controller classes
│   └── RouteRegistrarInterface.php Contract for route registration
│
├── Traits/
│   └── HttpMethodsTrait.php        get(), post(), put(), etc.
│
└── Utils/
    └── Path.php                    URL path utilities

Route Registration

Basic Routes

$router->get('/users', [UserController::class, 'index']);
$router->post('/users', [UserController::class, 'store']);
$router->put('/users/{id:int}', [UserController::class, 'update']);
$router->patch('/users/{id:int}', [UserController::class, 'update']);
$router->delete('/users/{id:int}', [UserController::class, 'destroy']);

Closure Handlers

$router->get('/health', function (ServerRequestInterface $request): ResponseInterface {
    return new Response(200, [], json_encode(['status' => 'ok']));
});

String Handlers

$router->get('/users', 'App\Controllers\UserController@index');

Route Parameters

$router->get('/users/{id:int}', ...);           // integer
$router->get('/posts/{slug:slug}', ...);         // slug (a-z, 0-9, hyphens)
$router->get('/files/{name}', ...);              // any (no constraint)
$router->get('/items/{uuid:uuid4}', ...);        // UUID v4
$router->get('/docs/{id:mongo_id}', ...);        // MongoDB ObjectId

Optional Parameters

$router->get('/posts/{page:int?}', function (ServerRequestInterface $req, int $page = 1): ResponseInterface {
    // GET /posts     → page = 1
    // GET /posts/3   → page = 3
});

Optional works at any position — beginning, middle, or end:

$router->get('/{lang:locale?}/users', ...);
// GET /users      → lang not set
// GET /en/users   → lang = "en"
// GET /ar/users   → lang = "ar"

Wildcard (Catch-All)

$router->get('/docs/{path:*}', function (ServerRequestInterface $req, string $path): ResponseInterface {
    // GET /docs/guide/install → path = "guide/install"
});

Route Naming

$router->get('/users/{id:int}', [UserController::class, 'show'])->name('users.show');

// Generate URL
$router->url('users.show', ['id' => 42]);                        // /users/42
$router->url('users.show', ['id' => 42], ['tab' => 'posts']);    // /users/42?tab=posts

Where Constraints

$router->get('/items/{code}', [ItemController::class, 'show'])
    ->where('code', 'slug');

Custom Patterns

$router->addPattern('short_id', '[a-zA-Z0-9]{8}');
$router->get('/links/{code:short_id}', [LinkController::class, 'redirect']);

Groups

$router->group('/api/v1', function (RouteGroup $group): void {
    $group->get('/users', [UserController::class, 'index']);
    $group->get('/users/{id:int}', [UserController::class, 'show']);
    $group->post('/users', [UserController::class, 'store']);

    $group->group('/admin', function (RouteGroup $admin): void {
        $admin->get('/stats', [StatsController::class, 'index']);
    });
})->middleware(AuthMiddleware::class);

Groups accumulate prefixes and middleware. Nested groups inherit from their parent.

Middleware

Middleware implements PSR-15 MiddlewareInterface:

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class AuthMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $token = $request->getHeaderLine('Authorization');

        if ($token === '') {
            return new Response(401, [], 'Unauthorized');
        }

        return $handler->handle($request);
    }
}

Global Middleware

$router->middleware(CorsMiddleware::class);
$router->middleware(AuthMiddleware::class);

Runs in registration order for every matched route.

Route Middleware

$router->get('/admin/stats', [StatsController::class, 'index'])
    ->middleware(AdminMiddleware::class);

Closure Middleware

$router->middleware(function (ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
    $response = $handler->handle($request);
    return $response->withHeader('X-Request-Id', uniqid());
});

Scopes

Reusable preset bundles of middleware and hosts:

$scope = new RouteScope('api');
$scope->middleware(AuthMiddleware::class);
$scope->middleware(RateLimitMiddleware::class);
$scope->host('api.example.com');

$router->addScope($scope);

$router->get('/data', [DataController::class, 'index'])
    ->scope($router->getScope('api'));

Host Routing

$router->get('/dashboard', [DashboardController::class, 'index'])
    ->host('admin.example.com');

Base Path (subfolder deployments)

Deploy at http://example.com/site/admin/ or http://example.com/api/v1/? Set the base path once and routes stay deployment-agnostic:

$router->setBasePath('/site/admin');

$router->get('/', [DashboardController::class, 'index']);
$router->get('/users/{id:int}', [UserController::class, 'show']);

Routes match against the path after the base is stripped, so /site/admin/ resolves to /, /site/admin/users/42 resolves to /users/42. Multi-segment base paths work the same way.

The base path is stripped from incoming request URIs before route matching. Routes never reference the deployment URL — only your setBasePath() call does.

$router->setBasePath((string) env('APP_BASE_PATH', ''));

Why explicit, not auto-detected: auto-detection via $_SERVER['SCRIPT_NAME'] is FPM-specific and breaks under Swoole (where $_SERVER is populated once at worker boot, not per-request). setBasePath() is runtime-agnostic — works under FPM, Swoole, RoadRunner, FrankenPHP, etc.

setBasePath() normalises slashes ('site/admin', '/site/admin', '/site/admin/' all become /site/admin); the empty string disables stripping. Requests outside the configured base path return 404 (strict — /site/admin does not match /site/administrators/foo).

Fallback Handler

$router->fallback(function (ServerRequestInterface $request): ResponseInterface {
    return new Response(404, [], json_encode(['error' => 'Not Found']));
});

Controllers

Controllers must implement ControllerInterface:

use PHPdot\Routing\Contracts\ControllerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class UserController implements ControllerInterface
{
    public function show(ServerRequestInterface $request, int $id): ResponseInterface
    {
        return new Response(200, [], json_encode(['id' => $id]));
    }
}

Route parameters are passed as method arguments. The request also carries them as attributes:

$request->getAttribute('id');           // 42
$request->getAttribute('_route');       // Route object
$request->getAttribute('_route_params'); // ['id' => 42]

Path Utilities

use PHPdot\Routing\Utils\Path;

Path::segments('/users/42/posts');  // ['users', '42', 'posts']
Path::build(['users', '42']);       // /users/42
Path::first('/en/users');           // 'en'
Path::shift('/en/users/42');        // /users/42

PSR Standards

PSR Interface Usage
PSR-7 ServerRequestInterface Request input for matching and dispatch
PSR-7 ResponseInterface Handler and middleware return type
PSR-11 ContainerInterface Resolves controllers and middleware
PSR-15 RequestHandlerInterface Router implements this — $router->handle($request)
PSR-15 MiddlewareInterface Standard middleware contract
PSR-17 ResponseFactoryInterface Creates 404/405 responses

Performance

Compilation happens once at boot. Matching is constant-time regardless of route count.

Scenario Latency Throughput
Static routes ~0.6 µs 1.6M matches/sec
Dynamic routes ~0.85 µs 1.2M matches/sec
Worst case (1000 routes) ~0.78 µs 1.3M matches/sec
404 not found ~0.34 µs 2.9M matches/sec
Compilation (1000 routes) ~1.3 ms once at boot

Development

composer test        # Run tests
composer analyse     # PHPStan level 10
composer cs-fix      # Fix code style
composer cs-check    # Check code style (dry run)
composer check       # Run all three

License

MIT