phpdot / routing
High-performance segment-trie routing for PHP. PSR-7/15/17 compliant.
Requires
- php: >=8.3
- psr/container: ^2.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- nyholm/psr7: ^1.8
- phpdot/container: ^1.6
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.0
Suggests
- phpdot/container: Required for the framework's manifest scanner to auto-wire Router via the #[Singleton] attribute. The attribute is inert at runtime; standalone consumers don't need it installed.
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