Lightweight PHP router with route params, middleware, groups, CRUD, redirects, and named URLs.
composer require roolith/router
use Roolith\Route\Router;
require_once __DIR__ . '/PATH_TO_AUTOLOAD/autoload.php';
$router = new Router();
$router->setBaseUrl('http://localhost/your_project_root/');You can also pass settings to the constructor, which is equivalent to calling the setters below.
$router = new Router([
'base_url' => 'http://localhost/your_project_root/',
'view_dir' => __DIR__ . '/views',
'use_di' => true,
]);setBaseUrl(string $url) sets the base used for stripping the request URL and for building redirect and named URLs; getBaseUrl() returns it. setViewDir(string $dir) sets the directory used by getViewHtmlByStatusCode() for <code>.php error views. setUseDI(bool $useDI) switches controller dispatch between PHP-DI (true, default) and plain new Class() (false). Bare and trailing-slash bases behave identically (http://test.com and http://test.com/ build the same URLs).
$router->get('/', function() {
return 'Roolith router';
});
$router->run();run() matches the current method + path, runs middleware, then runs the handler, and returns $this. Unknown paths emit 404 Route doesn't exists. A path that exists for another method emits 405 Method Not Allowed. Allowed: ... with an Allow header. Unknown verbs (HEAD/TRACE/custom) never match directly and fall through to the same 405/404 logic. The 405 probe is side-effect free and does not populate route params.
$router->get('/test', function() {
return 'Test route';
});
$router->post('/test', function() {
return 'post content';
});
$router->put('/test', function() {
return 'put content';
});
$router->patch('/test', function() {
return 'patch content';
});
$router->delete('/test', function() {
return 'delete content';
});
$router->options('/test', function() {
return 'options content';
});All verbs accept a single path or an array of paths, so post(['a', 'b'], $cb), put(['a', 'b'], $cb), and the rest all register one route per path. Paths are stored with a leading /, so 'user' and '/user' are the same route. Trailing slashes are literal ('/a/' only matches '/a/'), so prefer slash-less registration. The string '0' is a valid path and registers as '/0'. Empty, null, or false paths and callbacks are skipped as no-ops.
$router->get('user/{id}', function($id) {
return 'User id '.$id;
});
$router->get('/user/{userId}/edit/{another}', function($userId, $another) {
return 'get content {userId}: '.$userId.' {another}: '.$another;
});Matched values are passed to the handler as positional payload in segment order. Static dots are literal (/file/about.html does not match /file/aboutXhtml). Params accept dots (/file/a.txt matches with payload ['a.txt']) and unicode slugs (/post/cafe-2026, /post/{slug} works end to end). Request URL segments are sanitized per RFC 3986 pchar plus unicode letters/numbers, with ?... query and #... fragment stripped per segment.
use Roolith\Route\Request;
use Roolith\Route\Response;
class ShowMiddleware extends \Roolith\Route\Middleware {
public function process(Request $request, Response $response): bool {
$id = $request->getParam('id');
$q = $request->getUrlParam('q');
return true;
}
}$request->getParam('id') returns a matched {id} value or false when absent. $request->getUrlParam('q') returns the sanitized $_GET['q'] value (spaces allowed) or null when absent. getRequestMethod() is uppercased, so a lowercase get still matches GET.
$router->get(['user', 'profile'], function() {
return ['name' => 'John', 'age' => 45];
});Returning an array or object sends JSON (Content-Type: application/json); returning a string sends HTML. This applies to closures and controller returns alike.
$router->match(['GET', 'POST'], '/user', function() {
return 'GET POST content.';
});match() uppercases entries and ignores unknown verbs, so ['GET', 'post', 'BOGUS'] registers only GET and POST. match() also accepts an array path and an optional fourth $name argument: $router->match(['GET', 'POST'], ['/a', '/b'], $cb, 'user'). name() and middleware() called after match() apply to the whole slice (both GET and POST routes), not just the last one.
Supported verbs for string and array callbacks: get/post/put/patch/delete/options/match/any.
$router->get('controller', 'Demo\Controller@index');
// Identical array form (also works with match()/any()):
$router->get('controller', [\Demo\Controller::class, 'index']);
$router->match(['GET', 'POST'], '/user', [\Demo\Controller::class, 'index']);
$router->any('/user', [\Demo\Controller::class, 'index']);
// With route param:
$router->get('user/{id}', [\Demo\Controller::class, 'show']);Note: [$instance, 'method'] and static [Class, 'staticMethod'] callables stay as-is and dispatch directly; non-static [Class, 'method'] normalizes to Class@method and uses DI.
With use_di: true the controller is resolved via the PHP-DI container (constructor dependencies injected); with use_di: false it is created with plain new Class(). Route params are passed to the controller method the same way as closures. A missing class emits one 404 (Class X doesn't exist); a missing method emits one 404 (method doesn't exist in X); a string without @ emits one 500 (Invalid controller reference ...); an invalid array shape (wrong count, non-string parts, empty-string parts) or other residual handler emits one 500 (Invalid route handler). ['0', '0'] is allowed and normalizes to '0@0', while ['', ''] is skipped as a no-op. DI or legacy instantiation failures emit one generic 500 without leaking the raw exception; the detail is written to error_log.
$router->get('controller', 'Demo\Controller@index')->name('controller.index');name() appends to the whole last registration slice, so after match(), any(), or an array path every registered route gets the name. Inside a group the group namePrefix is prepended first, then name() appends. match() and any() also accept the name inline as the last argument.
$router->any('any', function() {
return 'any content. Server request method:'. $_SERVER['REQUEST_METHOD'];
});any() registers one route per known method (GET, POST, PUT, PATCH, DELETE, OPTIONS), accepts a string or array path, and accepts an optional third $name argument. name() and middleware() after any() apply to all 6 routes.
$router->crud('/crud', function () {
return 'crud content.';
});Above example registers 10 routes (one per method expansion) and is equivalent to
$router->get('/crud', function() {})->name('crud.index');
$router->get('/crud/create', function() {})->name('crud.create');
$router->get('/crud/{param}', function() {})->name('crud.show');
$router->get('/crud/{param}/edit', function() {})->name('crud.edit');
$router->post('/crud', function() {})->name('crud.store');
$router->post('/crud/{param}', function() {})->name('crud._update');
$router->post('/crud/{param}/delete', function() {})->name('crud._destroy');
$router->put('/crud/{param}', function() {})->name('crud.update');
$router->patch('/crud/{param}', function() {})->name('crud.update');
$router->delete('/crud/{param}', function() {})->name('crud.destroy');If there is controller
$router->crud('/crud', 'Controller');Above example is equivalent to
$router->get('/crud', 'Controller@index')->name('crud.index');
$router->get('/crud/create', 'Controller@create')->name('crud.create');
$router->get('/crud/{param}', 'Controller@show')->name('crud.show');
$router->get('/crud/{param}/edit', 'Controller@edit')->name('crud.edit');
$router->post('/crud', 'Controller@store')->name('crud.store');
$router->post('/crud/{param}', 'Controller@update')->name('crud._update');
$router->post('/crud/{param}/delete','Controller@destroy')->name('crud._destroy');
$router->put('/crud/{param}', 'Controller@update')->name('crud.update');
$router->patch('/crud/{param}', 'Controller@update')->name('crud.update');
$router->delete('/crud/{param}', 'Controller@destroy')->name('crud.destroy');crud() also accepts an array base: [Controller::class] or [Controller::class, 'ignored'] expands to Controller@<crudMethod> per route (the original array method is ignored). Closures are reused as-is for all crud routes.
$router->redirect('/redirect', '/redirected');
$router->redirect('/redirect', '/redirected', 302);redirect() defaults to 301 and registers the source for all 6 methods. Relative targets are joined to the base URL with exactly one slash; absolute targets containing :// are kept as-is (plain httpfoo is treated as a relative path). Redirect sources are literal paths: optional placeholders such as /a/{x?} are not expanded, so register each concrete source with a separate call. Redirects honor group urlPrefix/namePrefix, and dispatch is no-exit by design: the Location header is sent (CRLF-sanitized, skipped when headers were already sent) with the route status code, then execution continues.
$router->get('name/{name?}', function($name = 'Default name') {
return "Your name is - $name";
});Optional segments expand to prefix routes: 'name/{name?}' registers /name and /name/{name}. Multiple optionals expand as a prefix chain ('a/{b?}/c/{d?}' registers /a, /a/{b}/c, /a/{b}/c/{d} with no duplicates and no /a/c shortcut). A leading optional maps its empty prefix to root ('{lang?}/about' registers / and /{lang}/about). Give the handler a default value for the missing-param case.
$router->get('/admin/dashboard', function() {
return 'Dashboard content';
})->middleware(\Demo\AuthMiddleware::class);->middleware(X::class) or ->middleware(new X()) works per route and in groups, for both styles. Class-strings resolve via DI with new fallback. Unknown entries emit one 500; throwing middleware is logged and emits one generic 500.
Legacy (BC): extend Roolith\Route\Middleware with process(Request $request, Response $response): bool. Return true to continue, false to block with Invalid request + $status_code (default 403). All-legacy routes keep the original loop.
New-style (onion): implement Roolith\Route\Interfaces\NextMiddlewareInterface (Roolith\Route\NextMiddleware is a thin alias) with process(Request $request, callable $next): mixed. Order is registration order, outer group first: M1-pre -> M2-pre -> handler -> M2-post -> M1-post. Legacy entries inside a mixed chain act as gates. Not calling $next means the controller never runs.
| Return | Meaning |
|---|---|
false (no $next call) |
Block with Invalid request + $status_code (default 403) |
Response |
Emit directly (status + headers + body; Location/3xx = redirect) |
string / array|object |
Emit as HTML / JSON via Response::body() |
true / null |
Continue automatically (same as return $next($request)) |
return $next($request) |
Pass handler result through, optionally transformed |
throw |
Log + single generic 500 |
Notes: a returned Response status wins over $status_code; a handler false passed through is 200 empty, not a block; handlers must return values (echoes inside the chain are discarded); handlers may return a Response directly; the pipeline catches handler throws (legacy without middleware lets them bubble).
use Roolith\Route\Interfaces\NextMiddlewareInterface;
use Roolith\Route\Request;
use Roolith\Route\Response;
// Auth gate with header post-processing.
final class AuthMiddleware implements NextMiddlewareInterface
{
public function process(Request $request, callable $next): mixed
{
if (/* session has user */ true) {
$result = $next($request);
if ($result instanceof Response) {
return $result->setHeader('X-Auth-Checked', '1');
}
$wrapped = new Response();
$wrapped->setHeader('X-Auth-Checked', '1');
$wrapped->renderBody((string) $result);
return $wrapped;
}
$redirect = new Response();
$redirect->setStatusCode(302);
$redirect->redirect('/login');
return $redirect;
}
}
// CSRF gate: safe methods pass, bad token is 403 without running the controller.
final class CsrfMiddleware implements NextMiddlewareInterface
{
public function process(Request $request, callable $next): mixed
{
if (strtoupper((string) $request->getRequestMethod()) === 'GET' || /* token valid */ false) {
return $next($request);
}
$forbidden = new Response();
$forbidden->setStatusCode(403);
$forbidden->renderBody('Invalid CSRF token.');
return $forbidden;
}
}
$router->get('/admin/dashboard', function () {
return 'Dashboard content';
})->middleware(AuthMiddleware::class);
$router->post('/form', [FormController::class, 'submit'])->middleware(CsrfMiddleware::class);Use renderBody() (no echo) when building a returned Response; body() echoes and is for final emission. setHeader() / getHeader() / getHeaders() store headers so Location and custom headers survive re-emission. redirect() stores Location and returns void, so call it on its own line before return $response;.
$router->group(['middleware' => \Demo\AuthMiddleware::class, 'urlPrefix' => 'user/{userId}', 'namePrefix' => 'user.'], function () use ($router) {
$router->get('profile', function ($userId){
return "profile route: User id: $userId";
})->name('profile');
$router->get('action/{actionId}', function ($userId, $actionId){
return "action route: User id: $userId and action id $actionId";
})->name('action');
});Both function ($router) { ... } and function () use ($router) { ... } styles work because the group callback receives this router instance. urlPrefix is joined and normalized ('user/' + '/profile' becomes /user/profile); placeholders in the prefix become leading handler args. namePrefix concatenates, middleware appends outer-first, and nested groups merge (outer + inner gives /outer/inner/p, outer.inner.p, [Outer, Inner]) with the outer settings restored afterwards. Route-level ->middleware() appends after group middleware. Groups also apply to redirect() paths/names. getGroupSettings() returns the current settings array or false when empty; setGroupSettings() / resetGroupSettings() manage them manually.
$active = $router->activeRoute();activeRoute() returns the route matched for the current request method + URL (with payload for {param} values) or null/falsy when nothing matches.
$router->getRouteList();Each entry has method, path, execute or redirect, name, code (redirects), middleware (when set), and payload (when matched with params).
echo $router->formattedRouteList();formattedRouteList() renders getRouteList() as a terminal-friendly ASCII table with Method, Path, Name, Action, and Middleware columns (- for missing values, Closure for closures, Redirect to <target> (<code>) for redirects, No routes registered. when empty). Roolith\Route\RouteTableRenderer::render($router->getRouteList()) is the underlying static call.
$router->getUrlByName('controller.index');
$router->getUrlByName('user.show', ['id' => 1]);Placeholders are replaced with urlencode()d values, so ['q' => 'a b/c'] becomes a+b%2Fc. First match wins on duplicate names. A missing name returns the base URL unchanged (empty base returns ''). {key?} is tolerated as {key}, and placeholder keys with regex chars are matched literally.
use Roolith\Route\Response;
$response = new Response();
$response->body('hello');
$response->body(['hello' => 'world']);
$html = $response->renderBody('hello');
$last = $response->getLastOutput();
$response->setStatusCode(404);
$code = $response->getStatusCode();
$response->setHeaderJson();
$response->setHeaderHtml();
$response->setHeaderPlain();
$has = $response->hasHeaderContentType();
$response->setHeader('X-Custom', '1');
$value = $response->getHeader('X-Custom');
$all = $response->getHeaders();
$response->sendRaw('pre-rendered');
$response->errorResponse('Oops', 500);
$response->errorJson(['error' => 'Oops'], 500);
$response->redirect('http://example.com/target');body() echoes for BC and also stores/returns the rendered string via renderBody()/getLastOutput(); passing a vendor Response value unwraps it (status + stored headers + pre-rendered body, no double Content-Type). Status and Content-Type sends are skipped when headers were already sent (CLI/prior output safe). setHeader()/getHeader()/getHeaders()/hasHeader() store arbitrary headers (CR/LF stripped) so middleware Location and custom headers survive re-emission and stay testable; redirect() now stores Location too. sendRaw() echoes a pre-rendered body without touching headers or status (test/embedding hook kept for BC; pipeline emits via emitMiddlewareResponse()->body()). errorResponse() is HTML-only and defaults to 500 (was 403); pass 403/404 explicitly where needed. errorJson() mirrors the same status contract with a JSON body. outputJson() throws RuntimeException('JSON encoding failed: ...') on encoding failure. redirect() strips CR/LF and does not exit.
$router->setViewDir(__DIR__ . '/views');
$html = $router->getViewHtmlByStatusCode(404, 'fallback message');When view_dir is set, <code>.php under that dir is included with exactly $statusCode and $message available (isolated static scope, no $this, no $filePath leak); otherwise the fallback $message is returned as-is. Missing view files also return $message.
./vendor/bin/phpunit --testdox tests --stderr