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:

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

php
$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):

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

php
$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

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

php
$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):

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

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

php
$router->resource('tags', TagController::class);
MethodURINameAction
GET/tagstags.indexindex
GET/tags/createtags.createcreate
POST/tagstags.storestore
GET/tags/{id}tags.showshow
GET/tags/{id}/edittags.editedit
PUT/tags/{id}tags.updateupdate
DELETE/tags/{id}tags.destroydestroy

match() and fallback()

match() registers a single action for several methods:

php
$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):

php
$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).

php
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().

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

php
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');
    });
});
js
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:

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

php
$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

bash
./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):

bash
./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.

⏱ 10.89 ms 🗄 0 requête(s) SQL 🧠 4.00 MB ↩ 200