Skip to content

Router

composer require quillstack/router

The routing library working with PSR-7 requests.

Routes are registered once and matched against the request. A path without parameters is found by a straight lookup; one with them walks a tree, so adding routes does not make matching slower in the way a list of patterns tried in turn does.

Why this exists

A router that only answers which controller leaves the interesting cases to whoever calls it. A path nobody registered and a path registered for a different method are different answers, and an API that returns 404 for both is lying about one of them. HEAD is answered by whatever answers GET, unless somebody registered HEAD separately. And a route that requires authentication should say so where it is declared, not in the controller where it can be forgotten.

So dispatching here gives back a route object rather than a match: it knows whether it succeeded, which methods a known path does allow, what the parameters matched, and whether reaching it requires anybody. One middleware enforces that last one, and a guarded route in an application with nothing to enforce it refuses to boot — see quillstack/auth.

Requirements

  • PHP 8.1 or newer

Installation

shell
composer require quillstack/router

Usage

Registering

php
use Quillstack\Router\Router;

$router = new Router();

It needs nothing to be built, and neither does the dispatcher: new Dispatcher($router).

php
$router->get('/', HomeController::class)->name('home');
$router->post('/users', CreateUserController::class)->name('users.create');
$router->delete('/users/:id', DeleteUserController::class)->name('users.delete');

get(), post(), put(), patch(), delete(), options() and head() register one method. map('GET', …) takes the method as an argument, match(['PUT', 'PATCH'], …) takes a few of them, and any() takes them all.

Parameters

A segment written as :id or as {id} is a parameter:

php
$router->get('/users/:user/posts/:post', UserPostController::class)->name('user.post');

The values come back on the route, and the framework puts them on the request as attributes:

php
$route = $dispatcher->dispatch($request);

$route->getParameters();          // ['user' => '42', 'post' => '7']
$route->getParameter('user');     // '42'
$route->getParameter('page', '1') // '1' — nothing matched, so the default

A literal segment always wins over a parameter, whichever was registered first:

php
$router->get('/users/me', MeController::class);
$router->get('/users/:id', UserController::class);

// GET /users/me   → MeController
// GET /users/42   → UserController

Naming, and finding by name

php
$router->get('/users/:id', UserController::class)->name('users.show');

$router->getRoute('users.show')->getPath();   // '/users/:id'
$router->getRoutes();                          // every route, keyed by `METHOD /path`

Guarding a route

A route says what reaching it requires, and one place enforces it — a rule kept in each controller instead is a rule which is one day not kept:

php
$router->get('/orders', OrdersController::class)->requireAuthentication();
$router->delete('/orders/:id', DeleteOrderController::class)->requireAuthentication('admin');
$router->match(['PUT', 'PATCH'], '/orders/:id', UpdateOrderController::class)
    ->requireAuthentication('admin', 'support');

Any one of the roles will do. Nothing is guarded unless it says so, so this changed no route anybody had already written.

A route which says this implements GuardedRouteInterface, which is kept apart from RouteInterface for the same reason: something implementing a route without answering these is a route nobody guards, which is what every route was before.

Enforcing it is quillstack/auth's job — this package only carries what was asked for.

Dispatching

Dispatcher::dispatch() answers with the route that matched, or with one of two standing for having matched nothing:

RouteMeans
NotFoundRoutenothing is registered for this path
MethodNotAllowedRoutethe path is registered, but not for this method

Both say false to isSuccess(), so anything only asking whether something matched keeps working. MethodNotAllowedRoute::getAllowedMethods() names the methods the path does answer to, which is what a 405 has to carry.

A path registered for GET also answers HEAD, which is what a server offering GET is expected to do — registering head() for the path still wins where it was done.

Dispatching reads the path out of the URI, so a query string does not turn a known route into a 404.

Technical documentation

ClassWhat it is
Routerwhere routes are registered, and what holds them
Dispatchermatches a PSR-7 request against them
Routeone registered route
Routes\NotFoundRoute, Routes\MethodNotAllowedRoutewhat matching nothing means
RouteTree\RouteTreeBuilder, RouteTree\RouteTreeFinderthe tree paths with parameters are matched in

Router::normalisePath() brings a path to the form used as a key: a leading slash and no trailing one, so /users/ and users are the same route.

RouteNameNotSetException is thrown by name() when there is no route to name — that is, when it is called before any routing method.

Benchmark

Measured with quillstack/benchmark on forty routes — five per resource across eight resources, most of them carrying a parameter — matching GET /projects/42. All four find the same route and read the same id. Runs are interleaved and unconcurrent, each figure is the median of five, and PHP is 8.5.7.

Version
quillstack/routerv0.8.0
nikic/fast-route1.3.1
symfony/routingv7.4.17
league/route6.2.0

Registering forty routes and matching one, in a fresh process:

TimeRelative
nikic/fast-route3.10 ms0.47×
symfony/routing4.91 ms0.74×
quillstack/router6.60 ms
league/route12.67 ms1.9×

Matching alone, from a router already built:

Per matchRelative
nikic/fast-route1.09 µs0.35×
symfony/routing, compiled and dumped2.11 µs0.67×
quillstack/router3.13 µs
league/route7.61 µs2.4×
symfony/routing, uncompiled22.9 µs7.3×

nikic/fast-route is three times faster at matching and this one is third of four, which is worth stating rather than arranging around. It is also a different job: fast-route takes a method string and a path string and gives back an array. This takes a PSR-7 request and gives back a route that knows whether it matched, which methods the path allows if it did not, and whether reaching it needs authentication — and that request has to be built and read, which is most of the gap.

The last row is Symfony's matcher without its dump, which is a build step this one does not have; the row above it is the fair one.

Tests

shell
composer test
composer test:coverage
composer stan

The rest of Quillstack

This is one component of Quillstack, a PHP framework which is as simple to use as it is strict about what it does.

License

MIT. See LICENSE.

Released under the MIT License.