Skip to content

Latest commit

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

ordinary/router

A fast PHP 8.5 HTTP router with a global parameter registry, named routes, URL generation, and attribute-based route discovery.

Key design principles

  • Global parameter registry — constraints are declared once (bookId is 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::Found result 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 generationcompile() returns an object that implements both DispatcherInterface and UrlGeneratorInterface.
  • No magic — zero autowiring, no hidden convention scanning. Every wire is traceable by reading the code.

Installation

composer require ordinary/router

Requires 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 Psr6Cache

Quick start

useOrdinary\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/42

Router constructor

newRouter(
trailingSlash: TrailingSlashMode::Strict, // default
autoHeadFromGet: true, // default: HEAD mirrors GET
)

TrailingSlashMode

CaseBehaviour
Strict/foo and /foo/ are distinct routes
Ignoretrailing slash stripped silently before matching
Redirecttrailing slash returns MatchStatus::RedirectRequired pointing to the canonical (slash-free) URL; root / is never redirected

Registering parameters

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 last

Range validation

integer() 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::Found

Wildcard parameters

A 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']

Registering routes

// 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.

Compiling and dispatching

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

MatchResultInterface properties

PropertyTypePopulated when
$statusMatchStatusalways
$handlermixedFound
$paramsarray<string, string>Found
$allowedMethodslist<string>MethodNotAllowed
$redirectTo?stringRedirectRequired

Always check $status before accessing other properties.

URL generation

$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'); // false

generate() validates every substituted value against its registered constraint and throws InvalidArgumentException on failure.

Caching

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.

File cache (opcache-friendly)

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

PSR-16 (SimpleCache)

useOrdinary\Router\Cache\Psr16Cache;
$cache = newPsr16Cache($psrSimpleCachePool, key: 'app_routes');
$dispatcher = $router->compile(cache: $cache);

PSR-6 (CacheItemPool)

useOrdinary\Router\Cache\Psr6Cache;
$cache = newPsr6Cache($psrCachePool, key: 'app_routes');
$dispatcher = $router->compile(cache: $cache);

No-op 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;
}

Attribute-based route discovery

Declare routes directly on invokable action classes or controller methods using PHP attributes.

#[RouteClass] — invokable action class

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.

#[Route] — method on a controller

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'].

AttributeRouteLoader

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

Exception handling

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
}

Performance notes

  • 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 dataFileCache serializes the route table to a PHP file that opcache will cache on the first hit.

About

Fast PHP 8.5 HTTP router with a global parameter registry, named routes, URL generation, and attribute-based route discovery.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages