PSR-7/PSR-15 router: routes, groups, middleware, named routes, JSON response helpers and a response emitter.
- PHP ^8.5 (
Uri\WhatWg\Urlchecks the base URL) - psr/http-message ^2.0, psr/http-server-handler ^1.0, psr/http-server-middleware ^1.0, psr/container ^2.0, psr/http-factory ^1.1, nyholm/psr7 ^1.8.2, nyholm/psr7-server ^1.1
composer require sodaho/php-router// public/index.php
Sodaho\Router\Router::create()->loadRoutes(__DIR__ . '/../routes.php')->run();
// routes.php
use Sodaho\Router\{Response, RouteCollector};
return function (RouteCollector $r) {
$r->get('/users/{id:int}', fn ($request, int $id) => Response::success(['id' => $id]))->name('user.show');
$r->post('/users', [UserController::class, 'store']);
};| Signature | Description |
|---|---|
__construct(array $config = []) / static create(array $config = []): self |
Configuration. |
static fromEnv(array $config = []): self |
Adds the variables of Configuration. |
static boot(array $config, string $routesFile): void |
create(), loadRoutes(), run(). |
loadRoutes(string $file): self |
File returning function (RouteCollector $r) {…}. |
setContainer(ContainerInterface $container): self |
PSR-11 container for classes by name. |
setDebug(bool $debug): self / isDebug(): bool |
Debug mode, see Security. |
setBasePath(string $basePath): self |
Prefix of routes and addresses: /api, /api/, api alike. |
setBaseUrl(?string $baseUrl): self |
Base URL of absoluteUrl(); null, '', '0': none. |
middleware(string|array|object $middleware): self |
Middleware for every request, first = outermost. |
app(string $prefix, string $directory, array $options = []): self |
Web app folder, see AppFolder. |
setErrorHandler(callable $handler): self / on(string $event, callable $callback): static |
See below / Hooks. |
match(ServerRequestInterface $request): RouteMatch |
Looks the request up, runs nothing. |
handle(ServerRequestInterface $request): ResponseInterface |
PSR-15; does not throw. |
url(string $name, array $params = []): string / absoluteUrl(…) |
See UrlGenerator. |
run(): void |
Request from PHP's globals, handle(), send. |
emit(ResponseInterface $response, bool $withBody = true): void |
See run() and emit(). |
__clone() |
Throws RouterException after the first use. |
- The route table is built by the first request,
match(),url()orabsoluteUrl(). After that,setDebug(),setBasePath(),setBaseUrl(),loadRoutes()andclonethrowRouterException;setContainer(),middleware(),app(),on()andsetErrorHandler()take effect for later requests. - The routes file is required once per
loadRoutes(); its closure sees the router as$this. When the table cannot be built (no routes file set or found, the file threw or returned no callable, the callable threw, a route was refused),match()andurl()throw, and each request goes to the error handler (default: the router's 500) and toerror. What the callable registered on the router (middleware, apps, hooks) is taken back. - The error handler,
fn (Throwable $e, ServerRequestInterface $request): ?ResponseInterface, answers what route middleware, handlers and middleware for every request throw;nullor a throw gives the router's 500. - Order, outside in: middleware for every request, error handler, then 404, 405, 400 or the route middleware (groups outer to inner, then the route's own) and the handler.
- Request attributes:
RouteMatch::classon every request,Route::classon a hit,_route_paramsand each parameter under its own name. A middleware that passes the request on with another method or path has the route looked up again for everything further in.
| Signature | Description |
|---|---|
get(string $pattern, mixed $handler): Route |
Also post() put() patch() delete() options() head() |
match(array $methods, string $pattern, mixed $handler): Route |
RFC 9110 tokens, upper-cased. |
any(string $pattern, mixed $handler): Route |
GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD. |
redirect(string $from, string $to, int $status = 302): Route |
GET and HEAD, a RedirectHandler. |
group(string $prefix, callable $callback): void |
Prefix for the routes in $callback. |
middlewareGroup(string|array|object $middleware, callable $callback, array $attributes = []): void |
Middleware. |
attributeGroup(array $attributes, callable $callback): void |
Inner wins per key, also in middlewareGroup(). |
addPattern(string $name, string $regex): self / addPatterns(array $patterns): self |
Type for {x:name}. |
setPreserveTrailingSlash(bool $preserve): self |
true: mode strict (set by the router). |
getRoutes(): array / getPatterns(): array |
Route objects; types as name => regex. |
getData(): array |
[static routes, dynamic routes]; freezes the routes. |
A handler is [Controller::class, 'method'] (the container's entry, else new when the constructor needs no
argument), a callable ([$object, 'method'], a closure, an invokable object) or a RequestHandlerInterface.
Controllers and callables get the request first and each placeholder as a named argument; a RequestHandlerInterface
gets the request alone. A handler returns a ResponseInterface. A container entry that is no object of the named
class (for middleware: no MiddlewareInterface) is a 500.
| Type | Regular expression | Cast (failure: 400 INVALID_PARAMETER) |
|---|---|---|
none: {id} |
[^/]+ |
string |
int |
-?\d+ |
int; 01, -0 and overflow fail |
float |
-?\d+(?:\.\d+)? |
float; overflow fails |
bool |
true|false|0|1, any case |
bool |
alpha, alphanum, slug |
[a-zA-Z]+, [a-zA-Z0-9]+, [a-z0-9-]+ |
string |
uuid, ulid |
8-4-4-4-12 hex digits; 26 of [0-9A-Za-z] |
string |
any |
(?:[^/]+(?:/[^/]+)*(?:/(?=\z))?)?: slashes, no empty segment |
string |
Refused with RouterException where the route is written:
- A placeholder other than
{name}or{name:type}; a name that does not begin with an ASCII letter or_, is longer than 32 characters, is_route_paramsor stands twice in the pattern;[or]. - A pattern (and a
basePath) that is no plain path written decoded: backslash, control character,%XX,?,#,.or..segment. - An empty method list, a method that is no token; method and pattern taken (
DuplicateRouteException). addPattern(): a name other than[A-Za-z0-9_]+; a fragment with an unescaped#, a named group, a(*…)verb, a callout(?C…), or an option that turns onxorxx.- When the table is built: an undefined type, an own fragment that does not compile or closes its group, two routes
of different patterns under one name (
DuplicateRouteException, also fromnew UrlGenerator()).
Matching: static routes (no placeholder) first, then dynamic routes in order of definition. A request path with
%2F, %5C, a backslash, a control character or a ./.. segment (also encoded) is a 404 before the table is
asked. A 500 (reported to error): a parameter named like an attribute the request carries; a route expression PCRE
gives up on (backtrack limit, JIT stack); a placeholder named like the first parameter of a controller or callable.
Trailing slash mode strict (default): /users and /users/ are two routes; in group('/api'), get('') is
/api and get('/') is /api/. Mode ignore: trailing slashes of routes and requests are dropped (/ stays).
Built by the collector: __construct(array $methods, string $pattern, mixed $handler, array $middleware = [], ?string $name = null, array $attributes = []).
| Signature | Description |
|---|---|
Readonly: array $methods, string $pattern, mixed $handler |
Set by the collector. |
array $middleware, ?string $name, array $attributes |
Assignable until the table is built. |
middleware(string|array|object $middleware): self |
Appends middleware. |
name(string $name): self / attribute(string $key, mixed $value): self |
Name for url(); attribute. |
getAttribute(string $key, mixed $default = null): mixed |
Reads an attribute. |
- A string key names one middleware: the same key again in a route and its groups, or in the router's own list, is a
RouterException. A route attribute wins over its groups. - Once the table is built,
middleware(),name(),attribute()and assigning$middleware,$nameor$attributesthrowRouterException; writing into an array in place ($route->attributes['k'] = …) is anError.
| Signature | Description |
|---|---|
const FOUND, NOT_FOUND, METHOD_NOT_ALLOWED |
Values of $status. |
Readonly: int $status, string $method, string $path |
$path: see below. |
Readonly: ?Route $route, array $params, array $casts, bool $viaGet |
$params not cast; HEAD via GET. |
isFound(): bool / allowedMethods(): array |
Status FOUND / methods of the path, HEAD behind GET. |
$path is the path the table was asked with (decoded, no base path); where the table was not asked, the request
path (decoded outside the base path, as sent for a path refused before the table). At METHOD_NOT_ALLOWED, $route
is the path's GET route, else that of the first allowed method: its attributes are not those of the method asked
for. handle() takes over a RouteMatch this router made for the same method and path; any other is looked up again.
| Signature | Status |
|---|---|
success(mixed $data, ?string $message = null, ?array $meta = null) |
200 |
created(mixed $data, ?string $message = null, ?string $location = null) |
201, Location |
accepted(mixed $data, ?string $message = null) / noContent() |
202 / 204 without body |
paginated(array $items, int $total, int $page, int $perPage) |
200, meta.pagination |
error(string $message, int $status = 400, ?string $code = null, ?array $details = null) |
$status |
notFound(?string $resource = null, string|int|null $identifier = null) |
404 |
unauthorized(?string $message = null) / forbidden(?string $message = null) |
401 / 403 |
validationError(array $errors) / methodNotAllowed(array $allowedMethods) |
422 / 405 with Allow |
tooManyRequests(int $retryAfter) |
429, Retry-After |
serverError(?string $message = null, ?array $debug = null) |
500 |
html(string $content, int $status = 200) / text(string $content, int $status = 200) |
HTML / text, UTF-8 |
json(mixed $data, int $status = 200, string $contentType = 'application/json') |
$data, no envelope |
redirect(string $url, int $status = 302) |
REDIRECT_STATUSES: 301, 302, 303, 307, 308 |
setResponder(ResponderInterface $responder): void / reset(): void |
Set the responder / JsonResponder |
getResponder(): ResponderInterface |
Current responder |
download(string $content, string $filename, string $contentType = 'application/octet-stream', bool $inline = false)
file(string $path, ?string $filename = null, string $contentType = 'application/octet-stream', bool $inline = false,
?string $range = null, ?int $maxChunk = null) // 200, 206 or 416- Each helper returns a
ResponseInterface.tooManyRequests()below 0 andredirect()with another status throwRouterException;paginated()throwsInvalidArgumentExceptionfor$pageor$perPagebelow 1,$totalbelow 0, a last item beyondPHP_INT_MAX. file(): 200 or 206 with aFileStream,Content-Length,Accept-Ranges: bytes,X-Content-Type-Options: nosniff, length and range from the opened file; 416 with an empty body,Content-Range: bytes */<size>,Accept-RangesandContent-Length: 0.$rangeis the rawRangeheader: one rangebytes=a-b,a-or-ngives 206, an unsatisfiable one 416, anything else 200.$maxChunkcaps a 206 body. A path that is no readable file and$maxChunkbelow 1 throwRouterException. NoETag;If-Rangeis not looked at.- Filenames of
download()andfile(): control and bidi characters dropped,/and\become_, 200 bytes at most (an extension of up to 16 bytes with its dot kept),.,.., empty:download; ASCIIfilename="…", valid UTF-8 alsofilename*=UTF-8''….
Responder (Service\…; body: the array as JSON) |
Success | Error (4xx/5xx) |
|---|---|---|
JsonResponder |
{"success": true, "data", "message"?, "meta"?} |
{"success": false, "message", "error": {…}} |
RfcResponder(string $typeBaseUri = 'about:blank') |
{"data", "message"?, "meta"?} |
RFC 9457 problem+json |
error of JsonResponder holds message, code, details. RfcResponder: type from the code (NOT_FOUND →
<base>/not-found; about:blank without code or with the base about:blank, the default), title from the message,
status of the response, detail, instance and other keys from the details; type, title, status of the
details are dropped.
ResponderInterface: formatSuccess(mixed $data, ?string $message = null, ?array $meta = null): array,
formatError(string $message, ?string $code = null, ?array $details = null): array, getContentType(): string
(4xx/5xx) and getSuccessContentType(): string (2xx/3xx).
| Signature | Description |
|---|---|
__construct(array $routes = [], ?array $patterns = null) |
Routes; $patterns of getPatterns(). |
setBasePath(string $basePath): void |
Put in front of every address. |
setBaseUrl(?string $baseUrl): void |
null, '': none; else the rule of baseUrl. |
setIgnoreTrailingSlash(bool $ignore): void |
The router's mode ignore. |
setEncodeParams(bool $encode): void |
Deprecated since 2.2.0; false throws. |
url(string $name, array $params = []): string |
Relative address. |
absoluteUrl(string $name, array $params = []): string |
Base URL and url(). |
hasRoute(string $name): bool |
Whether a route has the name. |
url()encodes values withrawurlencode()— a slash the placeholder takes stays, each segment encoded — and the literal text of pattern and base path (:,@,!$&'()*+,;=stay). Parameters that are no placeholders are left out (no query string). Not checked: whether another route takes the address first.url()throwsRouterExceptionfor a missing ornullparameter, a float that is not finite, a value with a backslash or control character, values with which the address would not match the route or not pass its cast (12aor01for{id:int}, an empty segment in{x:any}), a.or..segment in the address, an address that begins with//.absoluteUrl()without base URL:RouterException. Unknown name:RouteNotFoundException.
| Signature | Description |
|---|---|
__construct(string $prefix, string $directory, array $options = []) |
Built by app(); options below. |
readonly string $prefix, const TYPES/HASHED |
No trailing slash ('': root); ext => type; bundler regex. |
owns(string $path): bool |
Whether the path lies under the prefix. |
serve(ServerRequestInterface $request, string $path): ?ResponseInterface |
The file, the start page or null. |
- Asked where the route table has no route for the path (
NOT_FOUND); a path the table knows for another method stays a 405. A GET or HEAD under the prefix gets the folder's file, otherwise the start page; a 404 where the last segment has a dot. The longest prefix decides; prefixes are relative to the base path. - Not served: files outside the resolved folder, hidden names (leading dot), unreadable files, extensions not in
types; paths with a control character, a backslash,%2F,%5C,//, or a segment that ends in a dot or space. A path with a colon is not looked up as a file. - The file is opened, then checked at the handle (resolved path, device and file number);
ETag, length and body come from that handle. The checks assume a folder writable for the deployment alone: a directory on the way swapped for a link and back between two checks is not detected, and a hard link is served as the file it names. - Files go out without
Content-Disposition, withnosniffand the options'Cache-Control.ETag: a hash of the content up to 64 KiB, aboveW/device, file number, time, size (none without file numbers). AnIf-None-Matchwith the file's tag, or*, gives 304; aRangecounts for GET withoutIf-Range.
| Signature | Description |
|---|---|
__construct(string $target, int $status = 302) |
Target with {name} placeholders. |
handle(ServerRequestInterface $request): ResponseInterface |
Fills them from _route_params. |
getTarget(): string / getStatus(): int |
Target and status as given. |
- Refused by the constructor: a control character other than a tab, a placeholder that is not
{name}, a status not inResponse::REDIRECT_STATUSES, a placeholder where scheme or host belong (a target with a scheme or//writes scheme and host before its first placeholder, the host closed by/,?or#).redirect()also refuses a placeholder that the source pattern lacks. handle()encodes each value as a whole (/becomes%2F). It throwsRouterExceptionfor a placeholder without value, parameters that are no array of finite scalar values, a rendering that changes scheme or host (an empty value before a slash), and a value that makes a.or..segment.
Contract\RouterInterface:handle(),match(),url(),absoluteUrl(); type against it to wrap the router.Traits\HasHooks:on(string $event, callable $callback): staticappends a callback;trigger()is protected.Middleware\RouteHandler:__construct(mixed $handler, ?ContainerInterface $container = null),handle().Middleware\MiddlewareHandler:__construct(MiddlewareInterface $middleware, RequestHandlerInterface $next),handle(ServerRequestInterface $request): ResponseInterface($middleware->process($request, $next)).Dispatcher(constants asRouteMatch, no path checks):__construct(array $staticRoutes, array $dynamicRoutes),dispatch(string $method, string $uri): array([status, route or methods, params, casts]),staticRoute(string $method, string $uri): ?Route,allowedMethods(string $uri): array.RouteDispatcher, the PSR-15 handler the router builds:__construct(array $dispatchData, ?ContainerInterface $container = null, string $basePath = '', string $trailingSlash = 'strict', bool $debug = false),setContainer(),setMiddleware(),setImplicitHead(),setApps(),setErrorResponder(?Closure)(without one, exceptions leavehandle()),match(),handle().Stream\FileStream, a read-only PSR-7 stream over a file or a slice:__construct(string $path, int $start = 0, ?int $length = null)throwsRouterExceptionfor a file it cannot open, size or position;read(),seek(),tell(),rewind(),eof(),getSize(),getContents(),__toString(),getMetadata(),close(),detach(),isReadable(),isSeekable();isWritable()isfalse,write()throws;__destruct()closes; the stream methods throwRuntimeException. Classes other than the exceptions arefinal.
Status, headers and (with withBody) the body size are read before the first byte. Header lines go out first, the
status line last. Fields that exist once per message (Content-Type, Content-Length, Location, ETag, …)
replace what the host set; others (Vary, Cache-Control, Set-Cookie, …) are added. The body is read in
emitChunkSize chunks.
| Refused | When | run() |
emit() |
|---|---|---|---|
| Body closed or detached; a getter throws | before sending | 500 | throws |
| Status outside 100–599 | before sending | 500 | throws |
| Reason phrase or header value with a control character other than a tab | before sending | 500 | throws |
Protocol version not \d or \d.\d; header name not an RFC 9110 token |
before sending | 500 | throws |
Transfer-Encoding; Content-Length not one value of at most 18 significant digits |
before sending | 500 | throws |
Content-Length on 1xx or 204; one other than 0 on 205 |
before sending | 500 | throws |
withBody true: a body of size other than 0 (or unknown) on 1xx, 204, 205, 304 |
before sending | 500 | throws |
withBody true, a status with content: Content-Length above 0, body size 0 |
before sending | 500 | throws |
No byte for emitIdleTimeout s; body shorter or longer than Content-Length |
after headers | reported | throws |
run()reports each case toerror. Output started before the router: nothing is sent,errorgets typeemit.- The answer to HEAD keeps the GET's
Content-Lengthwithout a body:run()sends it without reading the body (also for a POST a middleware passed on as HEAD),emit()needswithBody: false. - A request the PSR-7 objects refuse (
Host: x:99999999) gets 400 fromrun(), reported asRouterException"The request could not be read"; other failures building the request get 500.
| Key | Variable | Default | Allowed | Refused |
|---|---|---|---|---|
debug |
APP_DEBUG |
false |
bool, bool-like ('true', '0', 'on', 'no'), falsy |
other truthy values |
basePath |
ROUTER_BASE_PATH |
'' |
plain path; null, false |
other types; %XX, \, ?, #, ., .. |
baseUrl |
APP_URL |
null |
http(s)://host[:port][/path] |
other forms (user info, \, query, blank) |
trailingSlash |
ROUTER_TRAILING_SLASH |
'strict' |
'strict', 'ignore'; null, '' |
other values |
urlEncoding |
ROUTER_URL_ENCODING |
true |
values that mean on | off; other truthy values |
routesFile |
— | null |
string | other types |
implicitHead |
— | true |
as debug |
as debug |
emitChunkSize |
— | 8192 |
int or digit string, 1024 to 16777216 | other values |
emitIdleTimeout |
— | 30 |
seconds above 0 to 3600; string with 6 decimals at most | other values |
- An unknown key throws
RouterExceptionnaming the known keys.nullmeans the default. ForbaseUrl,null,'',false,0and'0'mean none. Variables are read byfromEnv()only. fromEnv()reads$_ENV, thengetenv($name, true); a key in the array wins, also withnull,falseor''.APP_DEBUGandROUTER_URL_ENCODINGhave to be boolean-like or empty.APP_ENVis not read.implicitHead: HEAD without a HEAD route runs the GET route (method staysHEAD), the body is cut,Allowlists HEAD behind GET. A static GET route wins over a dynamic HEAD route.
app() option |
Default | Allowed | Refused |
|---|---|---|---|
index |
'index.html' |
a file name | /, \, :, leading dot, control char, trailing dot or space |
types |
TYPES, null |
ext => type, null removes |
not array; ext not [a-z0-9]+; bad type; PHP sources |
immutable |
null |
regex on the path below the prefix, e.g. AppFolder::HASHED |
no valid regex |
cacheIndex |
'no-cache' |
Cache-Control of the start page; null: none |
empty, control char |
cacheImmutable |
'public, max-age=31536000, immutable' |
for paths immutable matches |
as cacheIndex |
cacheOther |
'no-cache' |
for every other file | as cacheIndex |
RouterException for an unknown option, a missing folder, a prefix in use, and a prefix that is no plain path (a
segment with a leading dot, a separator or control character, a trailing dot or space).
| Event | When | Payload |
|---|---|---|
dispatch |
The handler of a route returned | method, path, route, handler, params, duration |
notFound |
The router answers 404 | method, path |
methodNotAllowed |
405 | method, path, allowed_methods |
error |
An exception the router answers or reports | exception, method, path, status (last key) |
error |
run()/emit() found output started |
type ('emit'), message, exception, status |
hookError |
A hook threw | event, exception |
status: 400 for a parameter that does not cast and a requestrun()cannot read, 500 for other answers of the router, the status PHP sent for failures after the headers. A hook that throws does not change the answer; withouthookErrora line with event, exception class, file and line (no message) goes to stderr orerror_log().
| Class | When | Members |
|---|---|---|
RouterException |
What the router refuses, see above | getDebugMessage(): ?string |
DuplicateRouteException |
Method and pattern taken; one name on two patterns | |
RouteNotFoundException |
url(), absoluteUrl() with an unknown name |
debug message lists the route names |
NotFoundException |
For applications; the router answers 404 itself | |
MethodNotAllowedException |
For applications; the router answers 405 itself | getAllowedMethods(): array |
- All extend
RouterException,__construct(string $message = 'Router error', int $code = 0, ?Throwable $previous = null, ?string $debugMessage = null);MethodNotAllowedExceptiondefaults$messageto'Method not allowed'and takesarray $allowedMethods = []fifth. The message says what is wrong;getDebugMessage()holds the details. handle()does not throw: what a handler, middleware, the routes file, the error handler, the responder or the request object throws becomes a 500 and goes toerror. Where the responder fails too, the 500 is plain text.
- Route parameters, hook
pathandRouteMatch::$pathcarry what the client sent: encode them for line-based logs; build a file path from{x:any}afterrealpath()and a prefix check. - Debug mode puts exception message, class, file, line and trace into the 500 body.
getDebugMessage()holds paths, patterns and values (RouteNotFoundException: the route names): log it, do not send it. run()takes scheme and host of the request URI fromX-Forwarded-ProtoandHost(nyholm/psr7-server): build links withabsoluteUrl()and a configuredbaseUrl.- Keep identities under namespaced class names (
App\Auth\Identity): no placeholder name has a\. - With
implicitHeadGET handlers run for HEAD requests (link scanners ask with HEAD). Response::file()andapp()open the path they are given: do not build it from request input.- Not included: a limit on the request line (web server), CSRF, CORS, authentication, rate limits.
composer test (PHPUnit; suites test:unit, test:integration, test:feature, test:security), composer analyse
(PHPStan: src level max, tests level 8), composer cs (dry run), composer validate --strict. No environment
variables; tests start PHP_BINARY -S 127.0.0.1:<free port> and PHP_BINARY -r.
See CHANGELOG.md.
MIT
Parts of this project (refactoring, documentation, code review) were developed with AI assistance (Claude).