Routing
Routing
Tout ce que Niang\Core\Router sait faire — vérifié directement contre son code source,
pas contre un souvenir de Laravel.
Routes de base
Une méthode par verbe HTTP, chacune retournant une RouteRegistration chaînable :
$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']);
Une action peut être [Controleur::class, 'methode'], la chaîne équivalente
'Controleur@methode', ou une closure. Le troisième argument (facultatif) est un tableau
de classes de middleware appliquées à cette route :
$router->get('/ping', function (Request $request): Response {
return Response::json(['pong' => true, 'time' => time()]);
}, [LogRequest::class]);
Sans route HEAD explicite pour une URI, une requête HEAD réutilise
automatiquement la route GET correspondante et vide simplement le corps de la
réponse. Une route HEAD explicite garde, elle, entièrement la main sur sa réponse.
Paramètres et contraintes
{nom} capture un segment d'URI (regex [^/]+ par défaut) et devient
accessible via $request->param('nom') ou, si un paramètre de méthode du contrôleur
porte le même nom, injecté directement (voir
injection par réflexion) :
$router->get('/hello/{name}', [HomeController::class, 'hello']);
// dans le contrôleur :
public function hello(string $name): Response { /* ... */ }
where() restreint un ou plusieurs paramètres par regex :
$router->delete('/posts/{id}', [PostController::class, 'destroy'], [Authenticate::class, VerifyCsrfToken::class])
->where(['id' => '[0-9]+']); // /posts/abc ne matche plus cette route
Nommer une route et générer une URL
$router->get('/contact', [ContactController::class, 'index'])->name('contact');
// ailleurs (vue, contrôleur, redirection) :
route('contact'); // '/contact'
route('posts.show', ['id' => 5]); // '/posts/5'
route() (l'helper global) appelle Router::url(), qui remplace chaque
{param} du gabarit enregistré par la valeur fournie — une simple substitution de
chaîne, pas un second passage par la regex de la route. Une route nommée inconnue lève une
\RuntimeException.
Liaison de modèle
Le paramètre devient directement la ligne du modèle, ou une 404 si elle n'existe pas :
$router->get('/posts/{post}', [PostController::class, 'show'])->bind(['post' => Post::class]); // par id
$router->get('/blog/{post}', [PostController::class, 'show'])->bind(['post' => Post::class . ':slug']); // par slug
public function show(array $post): Response // même nom que le paramètre : la ligne, ou 404
{
return $this->view('posts/show', ['post' => $post]);
}
La recherche passe par le modèle (suppression douce et $casts respectés) et a lieu
après les middlewares : un visiteur non authentifié ne peut pas sonder l'existence d'une ligne.
Elle ne vérifie pas les droits : pour /users/{user}/invoices/{invoice}, contrôlez que la facture
appartient à l'utilisateur (ou passez par une Policy). Compatible avec route:cache.
Groupes et préfixes
group() empile un préfixe d'URI et une liste de middleware, appliqués à toutes les
routes déclarées dans la closure (et restaurés à la sortie, donc imbricables) :
$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, avec HandleCors appliqué
Sous-domaines
domain() restreint les routes de sa closure à un hôte correspondant au gabarit — les
segments {param} du domaine (regex [^.]+, un label DNS) sont fusionnés
avec les paramètres d'URI :
$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"}
Routes ressource
resource() enregistre les 7 routes REST conventionnelles :
$router->resource('tags', TagController::class);
| Méthode | URI | Nom | 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() et fallback()
match() enregistre une seule action pour plusieurs méthodes :
$router->match(['GET', 'POST'], '/status-either', function (Request $request): Response {
return Response::json(['method' => $request->method]);
});
fallback() remplace la 404 par défaut quand aucune route ne correspond (utile pour un
catch-all applicatif) :
$router->fallback(fn () => Response::html('Page introuvable', 404));
HEAD et OPTIONS
Si l'URI correspond mais qu'aucune route ne répond à la méthode demandée, dispatch()
lève une HttpException(405) avec un en-tête Allow listant les méthodes
réellement disponibles pour cette URI — plutôt qu'une 404 trompeuse. OPTIONS n'est
jamais automatique : une route API qui doit répondre aux préflights CORS doit
déclarer sa propre route OPTIONS (voir l'exemple /api/posts ci-dessus),
sinon le préflight reçoit un 405 avant même d'atteindre le middleware CORS.
Réponses fichiers et cookies
Un fichier est lu depuis le disque au moment de l'envoi, sans être chargé en mémoire. Ni la compression gzip ni la barre de debug ne touchent un fichier ou un flux ; un fichier absent donne une 404, et le nom proposé peut contenir des accents (RFC 6266).
return Response::download(Storage::path($facture['path']), 'Facture été 2026.pdf'); // téléchargement
return Response::file(Storage::path($user['avatar'])); // affichage (image, PDF...)
return Response::stream(function () { // corps écrit au fil de l'eau
foreach (Order::query()->get() as $order) {
echo $order['reference'] . ';' . $order['total_cents'] . "\n";
}
}, 200, ['Content-Type' => 'text/csv']);
Response::file() n'affiche dans le navigateur que des types sûrs (images, PDF, texte,
MP3, MP4). Un fichier HTML ou SVG envoyé par un visiteur exécuterait son JavaScript sur votre
domaine : il est proposé en téléchargement. Pour servir un upload de storage/app/,
passez par une route qui vérifie les droits avant Response::file().
Un cookie peut accompagner n'importe quelle réponse, chiffré comme ceux de Cookie::set()
(voir Cookies chiffrés). Il n'est envoyé que si la réponse l'est, et
reste lisible dans les tests avec assertCookie() et assertCookieForgotten().
return Response::redirect('/')->cookie('theme', 'sombre', 60 * 24 * 30); // durée en minutes
return Response::redirect('/')->withoutCookie('theme');
// Hors d'un contrôleur (ex. un service) : part avec la réponse de la requête en cours
Cookie::queue('theme', 'sombre', 60);
Temps réel : Server-Sent Events
Pousser des mises à jour au navigateur (progression, notifications, tableau de bord) sans WebSocket ni dépendance :
use Niang\Core\Http\ServerSentEvent;
$router->get('/export/{id}/progression', function (string $id): Response {
return Response::eventStream(function () use ($id) {
while (($pourcent = Export::progression($id)) < 100) {
yield new ServerSentEvent(['pourcent' => $pourcent], event: 'progress');
sleep(1);
}
yield new ServerSentEvent(['pourcent' => 100], event: 'done');
});
});
const source = new EventSource('/export/42/progression');
source.addEventListener('progress', e => barre.value = JSON.parse(e.data).pourcent);
source.addEventListener('done', () => source.close());
Chaque yield part immédiatement : une chaîne telle quelle, toute autre valeur en JSON.
yield null ne produit rien mais envoie un commentaire : ping si rien n'est parti
depuis $heartbeat secondes (15 par défaut), pour que les proxys ne coupent pas la
connexion. Pendant le flux, la session est libérée (les autres onglets ne sont pas bloqués : écrivez en
session avant de commencer), et X-Accel-Buffering: no empêche Nginx de retenir les
événements. Le flux s'arrête à la fin du générateur ou, à un ou deux événements près, quand le client se
déconnecte.
Chaque flux occupe un processus PHP tant qu'il est ouvert : prévoyez assez de workers PHP-FPM. En
développement, PHP_CLI_SERVER_WORKERS=4 ./bin/niang serve permet de naviguer pendant
qu'un flux est ouvert.
Middleware de route
Les middleware d'une route s'exécutent en pipeline, dans l'ordre déclaré, chacun implémentant
Niang\Core\Middleware :
class Authenticate implements Middleware
{
public function handle(Request $request, \Closure $next): Response
{
return $next($request);
}
}
Chaque middleware décide d'appeler $next($request) (poursuivre le pipeline) ou de
retourner directement une Response (interrompre — une redirection vers /login,
par exemple). Les middleware d'un groupe et ceux passés à une route individuelle se fusionnent, dans
cet ordre : ceux du groupe d'abord, puis ceux de la route.
Arguments de middleware
Un middleware reçoit des arguments après :, séparés par des virgules, placés après $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 { /* ... */ }
}
Documentation OpenAPI
./bin/niang openapi # public/openapi.json, routes commençant par /api
./bin/niang openapi --prefix=/v2 --output=docs/api.json
./bin/niang openapi --prefix=/ # toutes les routes
Le fichier (OpenAPI 3.0) est généré à partir des routes réelles : chemins, méthodes, paramètres (typés
integer si leur contrainte where() est numérique), corps de requête déduit des règles de
la FormRequest injectée (formats, in, min/max, tableaux items.*.x,
fichiers en multipart/form-data), authentification (session ou Bearer), résumé tiré du docblock, et
réponses 401, 403, 404, 422 et 429 quand elles s'appliquent. Il s'ouvre dans Swagger UI, Redoc, Postman ou
Insomnia. Non déduits : une validation écrite dans l'action ($this->validate()) et la forme des
réponses. Fonctionnalité expérimentale (voir stabilité de l'API).
Cache de routes
./bin/niang route:cache précompile routes/web.php dans
storage/framework/routes.php (chargé directement au prochain
loadRoutes(), sans ré-exécuter le fichier de routes) :
./bin/niang route:cache
./bin/niang route:clear
./bin/niang route:list
Une route dont l'action est une closure n'est pas sérialisable : elle est silencieusement exclue du
cache (le nombre de routes ignorées est affiché), et continuera de fonctionner tant que le cache
n'est pas actif — mais pour qu'une route survive au cache, son action doit être
[Controleur::class, 'methode'], jamais une closure.