Routing
Routing
Everything Niang\Core\Router can do — checked directly against its source code, not
against a memory of Laravel.
Basic routes
One method per HTTP verb, each returning a chainable RouteRegistration:
$router->get('/posts', [PostController::class, 'index']);
$router->post('/posts', [PostController::class, 'store']);
$router->put('/posts/{id}', [PostController::class, 'update']);
$router->patch('/posts/{id}', [PostController::class, 'update']);
$router->delete('/posts/{id}', [PostController::class, 'destroy']);
$router->options('/posts', fn () => Response::html('', 204));
$router->head('/posts', [PostController::class, 'index']);
An action can be [Controller::class, 'method'], the equivalent string
'Controller@method', or a closure. The (optional) third argument is an array of
middleware classes applied to that route:
$router->get('/ping', function (Request $request): Response {
return Response::json(['pong' => true, 'time' => time()]);
}, [LogRequest::class]);
Without an explicit HEAD route for a URI, a HEAD request automatically
reuses the matching GET route and simply empties the response body. An explicit
HEAD route, on the other hand, keeps full control over its response.
Parameters and constraints
{name} captures one URI segment (regex [^/]+ by default) and becomes
available through $request->param('name') or, if a controller method parameter has the
same name, is injected directly (see
reflection-based injection):
$router->get('/hello/{name}', [HomeController::class, 'hello']);
// in the controller:
public function hello(string $name): Response { /* ... */ }
where() restricts one or more parameters with a regex:
$router->delete('/posts/{id}', [PostController::class, 'destroy'], [Authenticate::class, VerifyCsrfToken::class])
->where(['id' => '[0-9]+']); // /posts/abc no longer matches this route
Naming a route and generating a URL
$router->get('/contact', [ContactController::class, 'index'])->name('contact');
// elsewhere (view, controller, redirect):
route('contact'); // '/contact'
route('posts.show', ['id' => 5]); // '/posts/5'
route() (the global helper) calls Router::url(), which replaces every
{param} of the registered pattern with the given value — a plain string substitution,
not a second pass through the route's regex. An unknown route name throws a
\RuntimeException.
Route model binding
The parameter becomes the model row directly, or a 404 if it doesn't exist:
$router->get('/posts/{post}', [PostController::class, 'show'])->bind(['post' => Post::class]); // by id
$router->get('/blog/{post}', [PostController::class, 'show'])->bind(['post' => Post::class . ':slug']); // by slug
public function show(array $post): Response // same name as the parameter: the row, or 404
{
return $this->view('posts/show', ['post' => $post]);
}
The lookup goes through the model (soft deletes and $casts apply) and happens after
middleware: an unauthenticated visitor cannot probe whether a row exists. It doesn't check permissions:
for /users/{user}/invoices/{invoice}, check that the invoice belongs to the user (or use a Policy).
Compatible with route:cache.
Groups and prefixes
group() stacks a URI prefix and a list of middleware, applied to every route declared
inside the closure (and restored on exit, so groups can be nested):
$router->group(['prefix' => '/api', 'middleware' => [HandleCors::class]], function ($router) {
$router->get('/posts', [PostController::class, 'apiIndex']);
$router->options('/posts', fn () => Response::html('', 204));
});
// -> GET/OPTIONS /api/posts, with HandleCors applied
Subdomains
domain() restricts the routes of its closure to a host matching the pattern — the
domain's {param} segments (regex [^.]+, one DNS label) are merged with the
URI parameters:
$router->domain('{tenant}.niangpro.test', function ($router) {
$router->get('/tenant', function (Request $request): Response {
return Response::json(['tenant' => $request->param('tenant')]);
});
});
// curl -H "Host: acme.niangpro.test" http://127.0.0.1:8000/tenant
// -> {"tenant":"acme"}
Resource routes
resource() registers the 7 conventional REST routes:
$router->resource('tags', TagController::class);
| Method | URI | Name | Action |
|---|---|---|---|
| GET | /tags | tags.index | index |
| GET | /tags/create | tags.create | create |
| POST | /tags | tags.store | store |
| GET | /tags/{id} | tags.show | show |
| GET | /tags/{id}/edit | tags.edit | edit |
| PUT | /tags/{id} | tags.update | update |
| DELETE | /tags/{id} | tags.destroy | destroy |
match() and fallback()
match() registers a single action for several methods:
$router->match(['GET', 'POST'], '/status-either', function (Request $request): Response {
return Response::json(['method' => $request->method]);
});
fallback() replaces the default 404 when no route matches (useful for an application
catch-all):
$router->fallback(fn () => Response::html('Page not found', 404));
HEAD and OPTIONS
If the URI matches but no route answers the requested method, dispatch() throws an
HttpException(405) with an Allow header listing the methods actually
available for that URI — rather than a misleading 404. OPTIONS is
never automatic: an API route that must answer CORS preflights has to declare its
own OPTIONS route (see the /api/posts example above), otherwise the
preflight gets a 405 before it even reaches the CORS middleware.
File responses and cookies
A file is read from disk when the response is sent, without loading it into memory. Neither gzip compression nor the debug toolbar touch a file or a stream; a missing file gives a 404, and the suggested name may contain accents (RFC 6266).
return Response::download(Storage::path($facture['path']), 'Invoice summer 2026.pdf'); // download
return Response::file(Storage::path($user['avatar'])); // display (image, PDF...)
return Response::stream(function () { // body written as it goes
foreach (Order::query()->get() as $order) {
echo $order['reference'] . ';' . $order['total_cents'] . "\n";
}
}, 200, ['Content-Type' => 'text/csv']);
Response::file() only displays safe types in the browser (images, PDF, text, MP3,
MP4). An HTML or SVG file uploaded by a visitor would run its JavaScript on your domain: it is
offered as a download instead. To serve an upload from storage/app/, go through a
route that checks permissions before Response::file().
A cookie can come with any response, encrypted like the ones from Cookie::set() (see
Encrypted cookies). It is only sent if the response is, and can be
read in tests with assertCookie() and assertCookieForgotten().
return Response::redirect('/')->cookie('theme', 'dark', 60 * 24 * 30); // duration in minutes
return Response::redirect('/')->withoutCookie('theme');
// Outside a controller (e.g. a service): sent with the current request's response
Cookie::queue('theme', 'dark', 60);
Real time: Server-Sent Events
Push updates to the browser (progress, notifications, dashboard) without WebSocket or dependency:
use Niang\Core\Http\ServerSentEvent;
$router->get('/export/{id}/progress', function (string $id): Response {
return Response::eventStream(function () use ($id) {
while (($percent = Export::progress($id)) < 100) {
yield new ServerSentEvent(['percent' => $percent], event: 'progress');
sleep(1);
}
yield new ServerSentEvent(['percent' => 100], event: 'done');
});
});
const source = new EventSource('/export/42/progress');
source.addEventListener('progress', e => bar.value = JSON.parse(e.data).percent);
source.addEventListener('done', () => source.close());
Each yield is sent immediately: a string as is, any other value as JSON. yield null
produces nothing but sends a : ping comment if nothing went out for $heartbeat
seconds (15 by default), so proxies don't drop the connection. During the stream the session is
released (other tabs aren't blocked: write to the session before starting), and
X-Accel-Buffering: no stops Nginx from holding events back. The stream ends when the
generator does or, within one or two events, when the client disconnects.
Each stream holds a PHP process while open: plan enough PHP-FPM workers. In development,
PHP_CLI_SERVER_WORKERS=4 ./bin/niang serve lets you keep browsing while a stream is open.
Route middleware
A route's middleware run as a pipeline, in the declared order, each one implementing
Niang\Core\Middleware:
class Authenticate implements Middleware
{
public function handle(Request $request, \Closure $next): Response
{
return $next($request);
}
}
Each middleware decides whether to call $next($request) (continue the pipeline) or to
return a Response directly (stop — a redirect to /login, for example). A
group's middleware and those passed to an individual route are merged, in this order: the group's
first, then the route's.
Middleware arguments
A middleware receives arguments after :, comma-separated, placed after $next:
$router->delete('/posts/{id}', [PostController::class, 'destroy'], [Authorize::class . ':posts.delete']);
class Authorize implements Middleware
{
public function handle(Request $request, \Closure $next, string ...$abilities): Response { /* ... */ }
}
OpenAPI documentation
./bin/niang openapi # public/openapi.json, routes starting with /api
./bin/niang openapi --prefix=/v2 --output=docs/api.json
./bin/niang openapi --prefix=/ # every route
The file (OpenAPI 3.0) is generated from the real routes: paths, methods, parameters (typed
integer when their where() constraint is numeric), request body inferred from the rules
of the injected FormRequest (formats, in, min/max, items.*.x arrays,
files as multipart/form-data), authentication (session or Bearer), summary from the docblock, and
401, 403, 404, 422 and 429 responses when they apply. It opens in Swagger UI, Redoc, Postman or Insomnia.
Not inferred: validation written inside the action ($this->validate()) and response shapes.
Experimental feature (see API stability).
Route caching
./bin/niang route:cache precompiles routes/web.php into
storage/framework/routes.php (loaded directly on the next loadRoutes(),
without re-running the routes file):
./bin/niang route:cache
./bin/niang route:clear
./bin/niang route:list
A route whose action is a closure cannot be serialized: it is silently left out of the cache (the
number of skipped routes is displayed), and will keep working as long as the cache is not active —
but for a route to survive caching, its action must be [Controller::class, 'method'],
never a closure.