This project includes a legacy HTTP router (Il4mb\\Routing\\Router) that supports attribute routes (#[Route(...)]) while delegating matching/decision-making to the core engine.
The goal of this adapter is:
- keep the public API familiar for HTTP controller routing
- keep routing decisions deterministic and observable
- keep the core engine protocol-agnostic and dependency-free
See runnable examples:
For a controller-focused guide (binding + reading inputs + writing responses), see:
When you call Router::dispatch(Request $request):
- The router builds an engine
RoutingContextfrom the HTTP request. - Attribute routes are compiled (and cached) into an engine
RouteTable. RouterEngine::route()selects the best route(s) deterministically.- The legacy execution pipeline runs:
- interceptors (
onDispatch→onBeforeInvoke→onInvoke) - per-route HTTP middlewares (
Il4mb\\Routing\\Middlewares\\MiddlewareExecutor) - controller callback execution (
Il4mb\\Routing\\Callback)
- interceptors (
Recommended for production:
manageHtaccess=false(avoid filesystem side-effects)decisionPolicy='first'for typical HTTP appsdecisionPolicy='error_on_ambiguous'for security-sensitive routingdebugTrace=falseunless troubleshooting
Example:
$router = new Router(options: [
'manageHtaccess' => false,
'decisionPolicy' => 'first',
'debugTrace' => false,
]);If your app is mounted under a sub-path (e.g. behind a reverse proxy, or served from /app), configure it explicitly:
basePath(string|null): Preferred option. A URL path prefix (e.g./api) that will be stripped before matching routes.pathOffset(string|null): Backward-compatible alias ofbasePath.autoDetectFolderOffset(bool): Whentrue(default), the router may infer an implicit base path from$_SERVER['SCRIPT_NAME'].
Precedence:
- If
basePathis set, it is used. - Else if
pathOffsetis set, it is used. - Else if
autoDetectFolderOffsetistrue, the router uses the folder ofSCRIPT_NAME(best-effort). - Else, no offset is applied.
Recommended for production:
$router = new Router(options: [
'basePath' => '/api',
'autoDetectFolderOffset' => false,
]);When debugTrace=true, the router stores engine trace information into the request:
Request::get('__route_trace'): list of structured trace eventsRequest::get('__route_decision'): selected route ids + reason + policy
This is intended for troubleshooting and building tooling.
When no route matches, the router will additionally check whether the same path/constraints match for a different HTTP method.
If so, it responds with:
- status code
405 Method Not Allowed - an
Allow: ...header listing the supported methods
This makes the legacy HTTP adapter behave more like a production HTTP router.
By default, the legacy adapter keeps its historical plain-text error body behavior.
You can opt into a consistent error format via router options:
errorFormat='text': plaintext/plainbody (defaults to the HTTP reason phrase)errorFormat='json': JSON body like{"error": {"code": 404, "message": "Not Found"}}errorExposeDetails=true: include exception message/class in the standardized response (recommended only for development)
Example:
$router = new Router(options: [
'manageHtaccess' => false,
'errorFormat' => 'json',
'errorExposeDetails' => false,
]);The attribute #[Route(...)] supports:
method(string)path(pattern)priority(higher wins)fallback(only considered if no non-fallback matches)host(exact or*.example.com)protocol(http,https, ...)headers(exact match or presence)metadata(arbitrary array for integrations)
If multiple non-fallback routes match the same request, the engine will rank them deterministically:
- higher
priority - higher specificity (static path beats captures/wildcards)
- stable tie-break by route id
Then decisionPolicy controls what happens:
first: execute only the best matchchain: execute all matches in ranked ordererror_on_ambiguous: return an error if more than one non-fallback matches
For a full worked example, see docs/routing.md.
The path string is compiled into the engine’s path matcher.
Common patterns:
- Static:
/health - Named segment:
/users/{id} - Regex segment:
/users/{id:[0-9]+} - Greedy wildcard:
/proxy/** - Greedy named (legacy):
/{path.*} - Greedy named (explicit):
/{rest:**}
Capture values:
- Captures are decoded using
rawurldecode(path-safe). This means+stays+. - Greedy named captures may match an empty remainder.
Example:
/{path.*}can match/and binds$path = ''(empty string).
If you write a fallback like:
#[Route(Method::GET, '/{path.*}', fallback: true)]
public function notFound(string $path, Response $res): array
{
$res->setCode(404);
return ['error' => 'not_found', 'path' => $path];
}then requesting / will correctly hit the fallback and receive path: "".
The router executes controller methods via the Callback binder.
A controller method can accept:
- route captures by parameter name (e.g.
{id}binds to$id) RequestandResponseobjects by type-hint$nextascallable(orClosure) to continue the chain
Example:
#[Route(Method::GET, '/users/{id}')]
public function show(int $id, Request $req, Response $res, callable $next)
{
return ['id' => $id];
}When building the argument list for your controller method, the binder applies a deterministic precedence:
- If a parameter name matches a route capture (e.g.
{id}→$id) AND the parameter type is scalar-ish (string|int|float|bool|mixedor unions containing them), bind from the capture. - Otherwise, try to inject from runtime arguments by type:
Request,Response, or other objects passed into the callbackcallable/Closurefor$next
- If nothing matches:
- use the PHP default value, if present
- otherwise, if the parameter allows null (
?T), usenull - otherwise, use
null(which may trigger a PHP type error if the signature disallows it)
This ensures class-typed parameters (like Request $request) are never accidentally populated from a same-named capture like /{request}.
Captures come from paths and are typically strings. The binder will cast when you type-hint:
int: numeric strings like"123"→123float: numeric strings like"3.14"→3.14bool:on/yes/true/1→true,off/no/false/0→falsestring: kept as string
Union types are supported (PHP 8):
int|string $idwill preferintif the capture looks like an integer; otherwisestring.
Nullable params are supported:
?string $xcan receivenullif no capture value is available.
Defaults are respected:
#[Route(Method::GET, '/opt')]
public function opt(?string $x = 'dflt', Request $req, Response $res, callable $next)
{
return $x; // "dflt" when no capture exists
}Note: optional path segments depend on the path pattern support in the matcher.
If a parameter is not matched and has a default value, the default is used.
Captures are decoded using path-safe semantics (rawurldecode).
This means + stays + (unlike urldecode, which treats + as space).
Run the lightweight test suite:
php -d zend.assertions=1 -d assert.exception=1 tests/run.php