Search by

A small router: register paths per HTTP method, then dispatch one request

Package info

github.com/umityatarkalkmaz/phpRoute

pkg:composer/umityatarkalkmaz/route

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 1

v2.1.0 2026-09-02 17:03 UTC

This package is auto-updated.

Last update: 2026-09-02 17:32:53 UTC


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.