umityatarkalkmaz / route
A small router: register paths per HTTP method, then dispatch one request
Requires
- php: >=8.2
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A small router: register paths per HTTP method, then dispatch one request.
Requirements
PHP 8.2 or newer.
Installation
composer require umityatarkalkmaz/route
Usage
use UmitYatarkalkmaz\Route; $router = new Route(); $router->get('/', static fn () => view('home')); $router->get('/users/:id', static fn (string $id) => view('user', findUser($id))); $router->post('/users', static fn () => createUser()); echo $router->dispatch();
dispatch() returns whatever the matched handler returns — it does not print
anything itself, so you decide what reaches the response.
Placeholders
:name in a path captures one segment and passes it to the handler, in the
order the placeholders appear. Everything else in the path is matched literally:
/api/v1.0 matches that path and not /api/v1X0.
Four names carry a built-in pattern:
| Placeholder | Matches |
|---|---|
:id |
[0-9]+ |
:slug, :url |
[0-9a-zA-Z_-]+ |
:any |
.+, including slashes |
Any other name matches one segment ([^/]+). Constrain a route's placeholders
with where(), which applies to the route it follows and to nothing else:
$router->get('/files/:name', $handler)->where(['name' => '[a-z]+']);
A constraint must be a valid pattern on its own or where() throws
InvalidArgumentException at registration; it is embedded as a group, so an
alternation inside it cannot reach past its placeholder.
Parameter values reach the handler exactly as they appear in the request
target — percent-encoding is not decoded. /users/%2e%2e arrives as the
literal %2e%2e, so decode with rawurldecode() yourself, and validate after
decoding rather than before. A path holding a . or .. segment is rejected:
registering one throws InvalidArgumentException, and requesting one is a miss.
Groups
$router->group('/admin', static function (Route $router): void { $router->get('/users', $handler); // /admin/users $router->get('/settings', $handler); // /admin/settings });
The prefix applies only inside the closure, and groups nest.
Controllers
$router = new Route(controllerNamespace: 'App\\Controller'); $router->get('/users/:id', 'UserController@show');
The handler string is resolved against controllerNamespace, or used as a
fully-qualified class name when none is set. A missing class or method throws
RuntimeException rather than failing quietly.
Misses
$router->setNotFoundHandler(static fn (string $method, string $path) => render404()); $router->setMethodNotAllowedHandler(static fn (string $method, string $path) => render405());
Without handlers, dispatch() sends a 404 or 405 status and returns null.
A path that matches under a different method is reported as 405, not 404,
with an Allow header naming every method the path is registered under. A
setMethodNotAllowedHandler() handler receives that list as its third argument
and is responsible for its own headers.
The unmatched path is never printed. Echoing it back reflects attacker-controlled input into the page.
Form method spoofing
Browsers only submit GET and POST. For the rest, the override must be
enabled explicitly — it is off by default, because it lets a form field
decide which method the router dispatches:
$router = new Route(allowMethodOverride: true);
<form method="post" action="/users/1"> <?= Route::renderMethodField('DELETE') ?> </form>
With the flag on, dispatch() honours _method on a POST request for PUT,
PATCH and DELETE only. With it off the field is ignored and the request
dispatches as POST.
Mounting under a subdirectory
$router = new Route(basePath: '/shop');
The base path is stripped from the request before matching, on a segment
boundary — mounting under /shop does not turn /shopadmin into /admin — and
it is stripped from an explicitly supplied path as well, so a test dispatches
the same path the browser requests.
Testing your routes
dispatch() takes an optional method and path, so routes can be exercised
without a web server:
self::assertSame('user 42', $router->dispatch('GET', '/users/42'));
License
MIT. See LICENSE.