A fast PHP 8.5 HTTP router with a global parameter registry, named routes, URL generation, and attribute-based route discovery.
- Global parameter registry — constraints are declared once (
bookIdis always an integer ≥ 1) and reused across every route that references{bookId}. No inline constraint syntax. - Full path declaration — routes are one string, no groups or prefix chains.
- Post-match validation — params are range-checked after the regex matches. A
MatchStatus::Foundresult guarantees all params are valid. - Compile once, dispatch many — safe for long-running processes (Swoole, RoadRunner, Amp). Route data can be cached to disk or a PSR-6/PSR-16 pool.
- Named routes + URL generation —
compile()returns an object that implements bothDispatcherInterfaceandUrlGeneratorInterface. - No magic — zero autowiring, no hidden convention scanning. Every wire is traceable by reading the code.
composer require ordinary/routerRequires PHP ≥ 8.5 and psr/http-message: ^2.0.
Optional cache adapters:
composer require psr/simple-cache # PSR-16 via Psr16Cache
composer require psr/cache # PSR-6 via Psr6CacheuseOrdinary\Router\Router;
useOrdinary\Router\MatchStatus;
useOrdinary\Router\TrailingSlashMode;
$router = newRouter();
// 1. Register parameters first$router->param('bookId')->integer(min: 1);
$router->param('status')->enum(BookStatus::class);
// 2. Register routes$router->get('/books', 'BookController::index', name: 'book.index');
$router->get('/books/{bookId}', 'BookController::show', name: 'book.show');
$router->post('/books', 'BookController::create', name: 'book.create');
$router->get('/books/by-status/{status}', 'BookController::byStatus');
// 3. Compile once$dispatcher = $router->compile();
// 4. Dispatch$result = $dispatcher->dispatch('GET', '/books/42');
match ($result->status) {
MatchStatus::Found => callHandler($result->handler, $result->params),
MatchStatus::NotFound => send404(),
MatchStatus::MethodNotAllowed => send405($result->allowedMethods),
MatchStatus::RedirectRequired => redirect($result->redirectTo),
};
// 5. Generate URLsecho$dispatcher->generate('book.show', ['bookId' => 42]); // /books/42newRouter(
trailingSlash: TrailingSlashMode::Strict, // default
autoHeadFromGet: true, // default: HEAD mirrors GET
)| Case | Behaviour |
|---|---|
Strict | /foo and /foo/ are distinct routes |
Ignore | trailing slash stripped silently before matching |
Redirect | trailing slash returns MatchStatus::RedirectRequired pointing to the canonical (slash-free) URL; root / is never redirected |
Call $router->param(name) and chain exactly one terminal method before registering any route that uses {name}.
// Built-in constraint types$router->param('id')->integer(min: 1); // digits, optional range$router->param('page')->range(min: 1, max: 100); // positive integer with explicit bounds$router->param('status')->enum(OrderStatus::class); // backed enum — alternation of case values$router->param('slug')->slug(); // [a-z0-9][a-z0-9-]*$router->param('token')->uuid(); // UUID v4 (case-insensitive)$router->param('word')->alpha(); // [a-zA-Z]+$router->param('code')->alphanumeric(); // [a-zA-Z0-9]+$router->param('anything')->any(); // [^/]+ (any single segment)$router->param('hex')->pattern('[0-9a-f]+'); // custom PCRE fragment$router->param('filePath')->wildcard(); // .+ — spans path separators, must be lastinteger() and range() store range bounds separately from the regex. After the regex matches, the dispatcher checks the integer value against the bounds. A value outside the range is treated as no-match (routing continues to the next candidate).
$router->param('id')->integer(min: 1);
// /items/0 → MatchStatus::NotFound (below minimum)// /items/abc → MatchStatus::NotFound (regex fails)// /items/42 → MatchStatus::FoundA wildcard param uses the pattern .+, which matches across path separators. It must be the last segment in the route path.
$router->param('filePath')->wildcard();
$router->get('/uploads/{filePath}', 'FileController::serve');
// /uploads/2024/january/cover.jpg → params: ['filePath' => '2024/january/cover.jpg']// Per-method convenience methods$router->get(string $path, mixed $handler, ?string $name = null): void
$router->post(string $path, mixed $handler, ?string $name = null): void
$router->put(string $path, mixed $handler, ?string $name = null): void
$router->patch(string $path, mixed $handler, ?string $name = null): void
$router->delete(string $path, mixed $handler, ?string $name = null): void
$router->head(string $path, mixed $handler, ?string $name = null): void
$router->options(string $path, mixed $handler, ?string $name = null): void
// Multiple methods at once — accepts HttpMethod enum cases, raw strings, or a mix$router->map(['GET', 'HEAD', HttpMethod::Post], '/resource', 'handler', name: 'resource');$handler is mixed — the router stores it as-is and returns it in MatchResult::$handler. Pass a controller string, an array [MyController::class, 'action'], a closure, or whatever your framework resolves.
When autoHeadFromGet is true (the default), registering a GET route automatically adds HEAD for the same path and handler.
$dispatcher = $router->compile();
// Dispatch by method string + path string$result = $dispatcher->dispatch('GET', '/books/42');
// Or from a PSR-7 request$result = $dispatcher->dispatchRequest($psrRequest);| Property | Type | Populated when |
|---|---|---|
$status | MatchStatus | always |
$handler | mixed | Found |
$params | array<string, string> | Found |
$allowedMethods | list<string> | MethodNotAllowed |
$redirectTo | ?string | RedirectRequired |
Always check $status before accessing other properties.
$dispatcher = $router->compile();
// Static route$dispatcher->generate('book.index'); // /books// Dynamic route — pass param values as string or int$dispatcher->generate('book.show', ['bookId' => 42]); // /books/42// Check existence$dispatcher->has('book.show'); // true$dispatcher->has('no.such'); // falsegenerate() validates every substituted value against its registered constraint and throws InvalidArgumentException on failure.
Compile once, store the compiled route data, reuse it across FPM requests or long-lived processes. Handlers are never serialized — only the route structure is cached.
useOrdinary\Router\Cache\FileCache;
$cache = newFileCache('/var/cache/routes.php');
$dispatcher = $router->compile(cache: $cache);
// First call: compiles + stores. Subsequent calls: loads from file.// Invalidate when routes change (e.g. during deployment)$cache->invalidate();useOrdinary\Router\Cache\Psr16Cache;
$cache = newPsr16Cache($psrSimpleCachePool, key: 'app_routes');
$dispatcher = $router->compile(cache: $cache);useOrdinary\Router\Cache\Psr6Cache;
$cache = newPsr6Cache($psrCachePool, key: 'app_routes');
$dispatcher = $router->compile(cache: $cache);useOrdinary\Router\Cache\NullCache;
// Useful in long-running processes where compile() runs once at startup$dispatcher = $router->compile(cache: newNullCache());All cache adapters implement CacheInterface:
interface CacheInterface
{
publicfunctionload(): ?CompiledRoutes;
publicfunctionstore(CompiledRoutes$routes): void;
publicfunctioninvalidate(): void;
}Declare routes directly on invokable action classes or controller methods using PHP attributes.
useOrdinary\Router\Attribute\RouteClass;
useOrdinary\Router\HttpMethod;
#[RouteClass(HttpMethod::Get, '/books/{bookId}', 'book.show')]
finalclass ShowBookAction
{
publicfunction__invoke(string$bookId): void { /* ... */ }
}The handler stored in the router is the fully-qualified class name string.
The attribute is repeatable, so one method can handle multiple routes.
useOrdinary\Router\Attribute\Route;
useOrdinary\Router\HttpMethod;
finalclass BookController
{
#[Route(HttpMethod::Get, '/books', 'book.index')]
publicfunctionindex(): void { /* ... */ }
#[Route(HttpMethod::Post, '/books', 'book.create')]
publicfunctioncreate(): void { /* ... */ }
}The handler stored in the router is [ClassName::class, 'methodName'].
useOrdinary\Router\Attribute\AttributeRouteLoader;
$router = newRouter();
// Register params first — attributes don't declare constraints$router->param('bookId')->integer(min: 1);
$loader = newAttributeRouteLoader($router);
// Load a single class$loader->loadClass(ShowBookAction::class);
// Load a single method$loader->loadMethod(BookController::class, 'index');
// Scan a directory recursively (token-parses files — no execution)$loader->loadDirectory(__DIR__ . '/Actions');
$dispatcher = $router->compile();Every exception thrown by this package implements Ordinary\Router\Exception\ExceptionInterface:
useOrdinary\Router\Exception\ExceptionInterface;
useOrdinary\Router\Exception\InvalidArgumentException;
useOrdinary\Router\Exception\LogicException;
try {
$router->compile();
} catch (InvalidArgumentException$e) {
// Unregistered param, invalid regex, non-BackedEnum class, etc.
} catch (LogicException$e) {
// Duplicate method+path, duplicate route name
} catch (ExceptionInterface$e) {
// Catch-all for any ordinary/router exception
}- Static routes are stored in a hash map — O(1) lookup regardless of route count.
- Dynamic routes are indexed by segment count — a two-segment path only evaluates regexes for two-segment patterns.
- Wildcard routes (
.+params) are tried last, after dynamic routes. - Compile once — call
compile()at application boot and reuse the returned dispatcher for the entire request or process lifetime. The dispatcher is immutable and safe for concurrent use. - Cache the compiled data —
FileCacheserializes the route table to a PHP file that opcache will cache on the first hit.