The routing library working with PSR-7 requests. Full documentation: https://quillstack.org/router
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.
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.
- PHP 8.1 or newer
composer require quillstack/routeruse Quillstack\Router\Router;
$router = new Router();It needs nothing to be built, and neither does the dispatcher: new Dispatcher($router).
$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.
A segment written as :id or as {id} is a parameter:
$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:
$route = $dispatcher->dispatch($request);
$route->getParameters(); // ['user' => '42', 'post' => '7']
$route->getParameter('user'); // '42'
$route->getParameter('page', '1') // '1' — nothing matched, so the defaultA literal segment always wins over a parameter, whichever was registered first:
$router->get('/users/me', MeController::class);
$router->get('/users/:id', UserController::class);
// GET /users/me → MeController
// GET /users/42 → UserController$router->get('/users/:id', UserController::class)->name('users.show');
$router->getRoute('users.show')->getPath(); // '/users/:id'
$router->getRoutes(); // every route, keyed by `METHOD /path`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:
$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.
Dispatcher::dispatch() answers with the route that matched, or with one of two standing for
having matched nothing:
| Route | Means |
|---|---|
NotFoundRoute |
nothing is registered for this path |
MethodNotAllowedRoute |
the 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.
| Class | What it is |
|---|---|
Router |
where routes are registered, and what holds them |
Dispatcher |
matches a PSR-7 request against them |
Route |
one registered route |
Routes\NotFoundRoute, Routes\MethodNotAllowedRoute |
what matching nothing means |
RouteTree\RouteTreeBuilder, RouteTree\RouteTreeFinder |
the 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.
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/router | v0.8.0 |
| nikic/fast-route | 1.3.1 |
| symfony/routing | v7.4.17 |
| league/route | 6.2.0 |
Registering forty routes and matching one, in a fresh process:
| Time | Relative | |
|---|---|---|
| nikic/fast-route | 3.10 ms | 0.47× |
| symfony/routing | 4.91 ms | 0.74× |
| quillstack/router | 6.60 ms | — |
| league/route | 12.67 ms | 1.9× |
Matching alone, from a router already built:
| Per match | Relative | |
|---|---|---|
| nikic/fast-route | 1.09 µs | 0.35× |
| symfony/routing, compiled and dumped | 2.11 µs | 0.67× |
| quillstack/router | 3.13 µs | — |
| league/route | 7.61 µs | 2.4× |
| symfony/routing, uncompiled | 22.9 µs | 7.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.
composer test
composer test:coverage
composer stanThis is one component of Quillstack, a PHP framework which is as simple to use as it is strict about what it does.
- quillstack/middleware — what calls this
- quillstack/auth — what enforces a guarded route
- quillstack/server-request — what is dispatched
- quillstack/framework — where it is all wired up
MIT. See LICENSE.