This document is a practical guide for writing controllers for the legacy HTTP router (Il4mb\\Routing\\Router).
It covers:
- controller method signatures and how arguments are bound
- how to read inputs (route params, query, body, JSON, files, cookies, headers)
- how to write outputs (string/array responses, status codes, headers, cookies, redirects)
- how to use
$nextand middlewares
For router lifecycle, production options, and routing semantics, see docs/http.md.
If your app is mounted under a URL prefix (e.g. you serve the app from /api), configure it explicitly on the router:
$router = new Router(options: [
'basePath' => '/api',
'autoDetectFolderOffset' => false,
]);A controller method is invoked through the Callback binder. You can declare parameters in any order.
Common signature:
use Il4mb\Routing\Http\Request;
use Il4mb\Routing\Http\Response;
use Il4mb\Routing\Http\Method;
use Il4mb\Routing\Map\Route;
final class UserController
{
#[Route(Method::GET, '/users/{id}')]
public function show(int $id, Request $req, Response $res, callable $next)
{
return ['id' => $id];
}
}- Route params by parameter name:
{id}binds to$id- supports scalar typing + union typing (see below)
- Request/Response objects by type-hint:
Request $req,Response $res
$nextcontinuation:callable $nextor\Closure $next
The binder applies this precedence:
- If
$paramNamematches a capture AND the param type is scalar-ish (string|int|float|bool|mixedor unions containing them), bind from the capture. - Else, inject from runtime arguments by type (e.g.
Request,Response,callablefor$next). - Else:
- use the parameter default value if present
- else use
null(may raise a PHP type error if your signature disallows it)
Captures are strings from the path, but are cast when you type-hint:
int $id:"123"→123float $x:"3.14"→3.14bool $flag:on/yes/true/1→true,off/no/false/0→falsestring $name: stays a stringint|string $id: prefersintif numeric; otherwisestring
Route captures are decoded with path-safe semantics (rawurldecode).
That means + stays + (unlike urldecode, which turns + into a space).
If you want your controller to accept domain-specific types (value objects) instead of raw strings/ints, you can.
By default, the binder includes a resolver that can build objects from a matching capture:
- static
::fromString(string) - static
::from(string) - or a public constructor that accepts a single scalar
Example:
final class UserId
{
public function __construct(public string $value) {}
public static function fromString(string $value): self { return new self($value); }
}
#[Route(Method::GET, '/users/{id}')]
public function show(UserId $id, Request $req, Response $res, callable $next)
{
return ['id' => $id->value];
}You can also register custom resolvers on the router (e.g. build an object from headers, cookies, or multiple captures)
via the router option parameterResolvers.
$method = $req->method; // e.g. "GET"
$path = $req->uri->getPath();
$host = $req->uri->getHost();
$protocol = $req->uri->getProtocol(); // http/https
$queryString = $req->uri->getQuery();
$fragment = $req->uri->getFragment();Convenience:
if ($req->isMethod('POST')) { /* ... */ }The request stores query params parsed from $_GET.
$page = $req->getQuery('page');
$search = $req->getQuery('q');You can also use get() (see “Unified get()” below).
$email = $req->getBody('email');
$password = $req->getBody('password');If the request is not multipart, the implementation attempts json_decode(file_get_contents('php://input'), true) and merges it into __body.
$user = $req->getBody('user');
// For JSON like: {"user": {"name": "A"}}Nested form names like items[0][price] are parsed into nested arrays.
$items = $req->getBody('items');Use:
$file = $req->getFile('avatar');Returned structure depends on whether you uploaded 1 file or many:
- single file:
['type','name','tmp_name','size','error'] - multi file: list of the same file arrays
$token = $req->getCookie('token');Headers are stored in a case-insensitive ListPair.
$accept = $req->headers['accept'];
$role = $req->headers['x-role'];
$contentType = $req->headers['content-type'];Helpers:
if ($req->isAjax()) { /* ... */ }
if ($req->isContent('application/json')) { /* ... */ }Request::get($key) resolves in this order:
- custom request props set via
$req->set() - body (
getBody()) - query (
getQuery())
Example:
// Will return, in order: props['userId'] then body['userId'] then query['userId']
$userId = $req->get('userId');Get everything (debug):
$all = $req->get('*');Set custom props:
$req->set('tenantId', 't-123');
$tenantId = $req->get('tenantId');Note: you cannot set fixed keys like __body, __queries, __files, __cookies.
If your controller returns a non-null value, the router sets it as response content:
- return
string→ response body is that string - return
array→ response will be JSON whenResponse::send()is used
Example:
return ['ok' => true];$res->setCode(201);
$res->setContentType('application/json');
$res->setContent(['created' => true]);Set headers:
$res->headers['x-request-id'] = 'abc';Set cookies:
$res->setCookie('token', 'xyz', ['httponly' => true, 'secure' => true, 'path' => '/']);Redirect:
return Response::redirect('/login');$next() continues to the next matched route.
This is only useful when you configure the router with:
decisionPolicy='chain'(multiple routes may be selected)
Example middleware-style routing:
#[Route(Method::GET, '/chain', priority: 10)]
public function a(Request $req, Response $res, callable $next)
{
$res->setContent(($res->getContent() ?? '') . 'A');
$next();
}
#[Route(Method::GET, '/chain', priority: 0)]
public function b(Request $req, Response $res, callable $next)
{
$res->setContent(($res->getContent() ?? '') . 'B');
}If you use decisionPolicy='first', you typically do not call $next().
The attribute supports a middleware list:
#[Route(Method::POST, '/users', middlewares: [AuthMiddleware::class])]
public function create(Request $req, Response $res, callable $next)
{
// ...
}Middlewares implement Il4mb\\Routing\\Middlewares\\Middleware and run before the controller.
If router option debugTrace=true, the router stores engine trace output into the request:
$req->get('__route_trace')$req->get('__route_decision')
This is useful when troubleshooting why a route did/didn’t match.