Search by

roolith / router

im4aLL

Simple PHP router

2.1.0 2026-09-08 06:22 UTC

This package is auto-updated.

Last update: 2026-09-08 06:24:09 UTC


README

Lightweight PHP router with route params, middleware, groups, CRUD, redirects, and named URLs.

Install

composer require roolith/router

Setup

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).

Basic Usage

$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.

More usage

$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.

Route param

$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.

Multiple route at once

$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.

Multiple method at once

$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.

Controller method

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.

Named route

$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.

Wildcard route

$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.

CRUD route

$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.

Redirect route

$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.

Optional param route

$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.

Middleware

$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;.

Group route

$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 route

$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.

Get all route list

$router->getRouteList();

Each entry has method, path, execute or redirect, name, code (redirects), middleware (when set), and payload (when matched with params).

Formatted route list

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.

Get route url by name

$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.

Responses

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.

Error views

$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.

For development

./vendor/bin/phpunit --testdox tests --stderr