Routing
The Nexus router is a trie keyed on path segments with PSR-7 attribute binding and per-route middleware. Routes are registered through the builder, then frozen into an immutable CompiledApplication at boot. There is no runtime route mutation.
HTTP verbs
One method per HTTP verb:
$app->get('/users', $handler);
$app->post('/users', $handler);
$app->put('/users/{id}', $handler);
$app->patch('/users/{id}', $handler);
$app->delete('/users/{id}', $handler);
Each returns a RouteBuilder for per-route configuration (middleware, name).
Path parameters
Wrap parameter names in braces. The router extracts each matched value as a PSR-7 request attribute:
$app->get('/orders/{id}', static function (ServerRequestInterface $req) {
$id = (string) $req->getAttribute('id');
return JsonResponse::ok(['id' => $id]);
});
Multiple parameters are extracted into separate request attributes:
$app->get('/projects/{org}/tasks/{taskId}', static function (ServerRequestInterface $req) {
$org = (string) $req->getAttribute('org');
$taskId = (string) $req->getAttribute('taskId');
// …
});
Parameters match a single path segment (anything except /). The matched value is always a string; cast or validate inside the handler.
Named routes
Tag a route so other code (URL generation, link headers, tests) can reference it without rebuilding the path:
$app->get('/orders/{id}', ShowOrderHandler::class)->name('orders.show');
Names must be unique per application. Lookup is constant-time.
Route groups
Share a path prefix and a middleware stack across a block of routes:
$app->group('/api/v1', static function ($group) {
$group->get('/orders', OrderListHandler::class);
$group->post('/orders', OrderCreateHandler::class);
$group->get('/orders/{id}', ShowOrderHandler::class);
})->middleware(ApiKeyMiddleware::class);
The group's middleware runs after the global pipeline and before each route's own middleware. Groups nest:
$app->group('/api/v1', static function ($g) {
$g->group('/admin', static function ($admin) {
$admin->get('/users', AdminUserListHandler::class);
})->middleware(AdminAuthMiddleware::class);
$g->get('/orders', OrderListHandler::class);
});
Nested groups inherit the parent prefix and middleware.
Closure vs class handlers
Closures are convenient for tiny routes:
$app->get('/health', static fn() => Response::ok());
Classes are the production default. The recommended shape is one invokable class per endpoint:
final class HealthHandler
{
public function __invoke(): ResponseInterface
{
return Response::ok();
}
}
$app->get('/health', HealthHandler::class);
The [Class, 'method'] form is available when one controller class serves multiple routes, but one class per endpoint is easier to test and inject:
$app->get('/orders', [OrderController::class, 'index']);
See Handlers for constructor injection.
Route discovery from attributes
For larger applications, declare routes on the handler class and point the discoverer at a directory:
use Monadial\Nexus\Http\Routing\Attribute\Route;
#[Route('GET', '/orders/{id}', name: 'orders.show')]
final class ShowOrderHandler
{
public function __invoke(ServerRequestInterface $req): ResponseInterface
{
// …
}
}
$app->discover(__DIR__ . '/src/Http/Handler');
The discoverer scans the directory for classes carrying #[Route]. Each class becomes one route. Per-route middleware:
#[Route(
method: 'POST',
path: '/orders',
name: 'orders.create',
middleware: [AuthMiddleware::class, RateLimitMiddleware::class],
)]
final class CreateOrderHandler { /* … */ }
A single class can serve multiple verbs:
#[Route('GET', '/orders/{id}')]
#[Route('HEAD', '/orders/{id}')]
final class ShowOrderHandler { /* … */ }
When to use discovery
| Application size | Recommendation |
|---|---|
| Fewer than 20 routes | Inline ->get/post(...) calls |
| 20+ routes, single team | Either; pick by preference |
| Domain-driven, many bounded contexts | Discovery (route lives next to handler) |
Mixing is fine — explicit ->get(...) calls and ->discover(...) coexist in the same application.
Route caching
Discovery scans files and parses attributes on every boot. Cache the compiled route table to any PSR-16 store for production:
use Psr\SimpleCache\CacheInterface;
$app->withRouteCache($psr16Cache, key: 'app-routes-v1')
->discover(__DIR__ . '/src/Http/Handler')
->compile();
On a cache hit, the discoverer is skipped entirely. Bump the cache key when you deploy new code; the framework treats the key as opaque.
Dispatcher internals
The dispatcher is a trie keyed on path segments, with parameter slots matching any single segment. Lookup is O(path length), not O(routes).
When two routes overlap, the most specific wins:
$app->get('/users/me', MyProfileHandler::class); // wins for /users/me
$app->get('/users/{id}', ShowUserHandler::class); // wins for /users/42
Literal segments beat parameter slots at the same level.
404 and 405
404 Not Found— no route matched. The default handler returnsResponse::notFound(). Override via$app->onException(NotFoundException::class, ...).405 Method Not Allowed— a route matched the path but not the verb. The default handler returns405with anAllowheader listing supported verbs.
Both flow through the standard exception handler middleware, so route matchers behave like any other handler: middleware sees them, error mappers can intercept them.
See also
- Handlers — how handlers are constructed and injected with dependencies.
- Middleware — the pipeline that wraps every route.
- Error Handling — customising 404 and 405 responses.