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 :

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']);

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 :

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

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

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

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

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

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

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"}

Routes ressource

resource() enregistre les 7 routes REST conventionnelles :

php
$router->resource('tags', TagController::class);
MéthodeURINomAction
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() et fallback()

match() enregistre une seule action pour plusieurs méthodes :

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

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

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

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

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

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

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 { /* ... */ }
}

Documentation OpenAPI

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

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

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