Skip to main content

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:

server.php
$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:

server.php
$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:

server.php
$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:

server.php
$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:

server.php
$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:

server.php
$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:

server.php
$app->get('/health', static fn() => Response::ok());

Classes are the production default. The recommended shape is one invokable class per endpoint:

src/Http/Handler/HealthHandler.php
final class HealthHandler
{
public function __invoke(): ResponseInterface
{
return Response::ok();
}
}
server.php
$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:

server.php
$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:

src/Http/Handler/ShowOrderHandler.php
use Monadial\Nexus\Http\Routing\Attribute\Route;

#[Route('GET', '/orders/{id}', name: 'orders.show')]
final class ShowOrderHandler
{
public function __invoke(ServerRequestInterface $req): ResponseInterface
{
// …
}
}
server.php
$app->discover(__DIR__ . '/src/Http/Handler');

The discoverer scans the directory for classes carrying #[Route]. Each class becomes one route. Per-route middleware:

src/Http/Handler/CreateOrderHandler.php
#[Route(
method: 'POST',
path: '/orders',
name: 'orders.create',
middleware: [AuthMiddleware::class, RateLimitMiddleware::class],
)]
final class CreateOrderHandler { /* … */ }

A single class can serve multiple verbs:

src/Http/Handler/ShowOrderHandler.php
#[Route('GET', '/orders/{id}')]
#[Route('HEAD', '/orders/{id}')]
final class ShowOrderHandler { /* … */ }

When to use discovery

Application sizeRecommendation
Fewer than 20 routesInline ->get/post(...) calls
20+ routes, single teamEither; pick by preference
Domain-driven, many bounded contextsDiscovery (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:

server.php
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:

server.php
$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 returns Response::notFound(). Override via $app->onException(NotFoundException::class, ...).
  • 405 Method Not Allowed — a route matched the path but not the verb. The default handler returns 405 with an Allow header 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.