mithridatem / router
Lightweight PHP routing library with configurable authorization and controller resolution.
Requires
- php: ^8.1
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Mithridatem Router est une bibliotheque PHP oriente PSR-4 qui fournit un mecanisme de routage HTTP minimaliste, testable et independant de tout framework.
Caracteristiques clefs
- API fluide pour declarer des routes GET, POST ou multi-methodes avec segments dynamiques
{id}et wildcards/*. - Resolution de controlleurs parametres via references (
Route::controller) compatible avec un conteneur de dependances. - Controle d'acces branchable grace a l'interface
GrantCheckerInterface(implementationsAllowAllGrantChecker,ArrayGrantChecker; le routeur utiliseArrayGrantChecker([])par defaut). - Contexte de requete abstrait (
RequestContextInterface) pour isoler les superglobales PHP et faciliter les tests. - Base path configurable (
Router::setBasePath) pour les applications hebergees derriere un prefixe. - Exceptions explicites (
RouteNotFoundException,UnauthorizedException,RouterException) pour une gestion d'erreur claire.
Architecture et compatibilite
Router reste le point d'entree public : la recherche des routes est assuree par
RouteCollection, la normalisation des chemins par PathNormalizer, et le controle
des grants ainsi que l'invocation des handlers par ControllerInvoker.
MiddlewarePipeline conserve l'ordre d'execution existant.
Cette separation est interne : les signatures de Router, les setters de resolvers,
les routes et le comportement de dispatch() sont conserves.
Voir la documentation d'architecture.
Contrat des routes
La classe Route implemente maintenant RouteInterface. Les applications peuvent
continuer a utiliser Route::get(), Route::post(), Route::controller(),
#[Route] et Router::map() sans modifier leurs declarations existantes.
Le contrat expose les methodes HTTP, le chemin, le handler, les grants,
les middlewares et les operations de correspondance. Il permet desormais
d'enregistrer une implementation personnalisee de route dans RouteCollection
ou directement avec Router::map().
Note de compatibilite : le type de retour declare de
RouteMatch::getRoute() est elargi de Route a RouteInterface.
Les routes historiques restent des objets Route, mais le code qui impose
statiquement Route comme type de retour doit etre verifie.
Voir l'architecture pour les details.
Groupes de routes (optionnel)
Un groupe permet de partager un préfixe, des grants et des middlewares entre plusieurs routes. Les grants spécifiques d'une route remplacent ceux du groupe ; les middlewares s'enchaînent (global → groupe → route). Les routes existantes restent inchangées.
Attention — autorisations : les grants de groupe ne constituent pas une protection obligatoire si une route déclare ses propres grants. Par exemple, un groupe avec
ROLE_ADMINet une route avecROLE_USERautorise un utilisateur ayant uniquementROLE_USERsur cette route. Les rôles d'une même liste sont évalués en OU, pas en ET. Vérifiez les grants effectifs de chaque route sensible et consultez les règles d'héritage et les bonnes pratiques. Voir le guide des groupes de routes pour la syntaxe PHP et les attributs de classe#[RouteGroup].
Installation
composer require mithridatem/router
En developpement local sur ce depot, pensez a executer composer dump-autoload apres toute modification de namespace.
Premiers pas
<?php require __DIR__ . '/vendor/autoload.php'; use Mithridatem\Routing\Route; use Mithridatem\Routing\Router; $router = new Router(); // Route simple $router->map(Route::get('/', fn () => 'Hello world')); // Route avec parametre dynamique $router->map(Route::get('/books/{id}', function (string $id) { return "Livre #$id"; })); echo $router->dispatch();
Par defaut dispatch() detecte la requete courante via Http\Request::fromGlobals(). Les anciens contextes restent acceptes. Pour des tests unitaires, injectez votre propre contexte :
use Mithridatem\Routing\Context\RequestContextInterface; final class FakeContext implements RequestContextInterface { public function __construct(private string $path, private string $method) {} public function getPath(): string { return $this->path; } public function getMethod(): string { return $this->method; } public function getBearerToken(): ?string { return null; } } $router->dispatch(new FakeContext('/books/42', 'GET'));
Requete HTTP et injection dans les actions
L'objet Http\Request donne acces aux parametres d'URL, aux champs de formulaire,
aux en-tetes et au corps brut. Il implemente RequestInterface, qui etend le
contrat RequestContextInterface existant.
use Mithridatem\Routing\Http\RequestInterface; $router->map(Route::post('/books/{id}', function (string $id, RequestInterface $request): string { $title = $request->post('title'); if (!is_string($title) || trim($title) === '') { return 'Le titre est obligatoire.'; } // Appeler ici le service metier. return 'Livre modifie.'; })); echo $router->dispatch();
La requete est injectee par son type, quelle que soit sa position dans la signature.
Les parametres de route restent transmis par nom. Ce mecanisme fonctionne aussi
avec les methodes annotees #[Route] et les resolvers de controleurs existants.
query('page', '1') lit les donnees d'URL ; post('title') lit celles du formulaire.
Le JSON se lit via getContent() puis se decode explicitement.
Un ancien contexte minimal reste compatible avec les actions existantes. Une action
demandant RequestInterface necessite un contexte compatible, sinon une
RouterException est levee.
Voir le guide des requetes HTTP, les exemples executables avec closures et avec Attributes.
Fichiers envoyes par formulaire
Request implemente aussi FileRequestInterface, qui etend RequestInterface
sans la modifier. Les implementations personnalisees existantes restent compatibles.
use Mithridatem\Routing\Http\FileRequestInterface; $router->map(Route::post('/upload', function (FileRequestInterface $request): string { $file = $request->file('cover'); if ($file === null || $file->getError() === UPLOAD_ERR_NO_FILE) { return 'Aucun fichier selectionne.'; } if (!$file->isValid()) { return 'Erreur de transfert : ' . $file->getError(); } // Metadonnees conservees : tmp_name, name, size et error. // Valider le contenu puis utiliser le service de stockage. return 'Fichier recu : ' . $file->getSize() . ' octets.'; }));
files('attachments') gere les fichiers multiples et les champs imbriques.
UploadedFile::moveTo($destination) encapsule move_uploaded_file() ; le nom
d'origine et le chemin temporaire restent consultables apres le deplacement.
La superglobale $_FILES reste intacte et utilisable avec l'ancienne methode.
Voir le guide des uploads et l'exemple avec formulaire.
Controlleurs et resolution
Lorsque vos handlers resident dans des classes, utilisez Route::controller() et laissez le routeur resoudre la methode correspondante :
$router->mapController( 'GET', '/admin/dashboard', App\Controller\AdminController::class, 'dashboard' );
Par defaut, DefaultControllerResolver instancie la classe sans dependances. Pour deleguer la resolution a un conteneur, fournissez votre propre implementation de ControllerResolverInterface :
$router->setControllerResolver(new ContainerAwareResolver($container));
Routage par Attributes et decouverte automatique
Depuis PHP 8.1, vous pouvez declarer les routes directement sur les methodes
des controleurs avec l'Attribute #[Route]. La decouverte automatique permet
de charger tous les controleurs du repertoire src/Controller/ (y compris
ses sous-repertoires) sans les enregistrer individuellement.
1. Configurer l'autoload PSR-4 de votre application
Dans le composer.json du projet utilisant la librairie :
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Apres une modification de l'autoload :
composer dump-autoload
Arborescence d'exemple :
mon-projet/
├── composer.json
├── public/
│ └── index.php
└── src/
└── Controller/
├── HomeController.php
└── Admin/
└── DashboardController.php
2. Declarer les routes dans les controleurs
Fichier src/Controller/HomeController.php :
<?php namespace App\Controller; use Mithridatem\Routing\Attribute\Route; class HomeController { #[Route(path: '/', method: 'GET')] public function index(): string { return 'Accueil'; } #[Route(path: '/books/{id}', method: 'GET')] public function show(string $id): string { return "Livre #$id"; } }
Fichier src/Controller/Admin/DashboardController.php :
<?php namespace App\Controller\Admin; use Mithridatem\Routing\Attribute\Route; class DashboardController { #[Route(path: '/admin/dashboard', method: 'GET', grants: ['ROLE_ADMIN'])] public function index(): string { return 'Tableau de bord'; } }
L'Attribute accepte aussi plusieurs methodes HTTP :
#[Route(path: '/endpoint', method: ['GET', 'POST'])].
3. Charger automatiquement les routes
Dans public/index.php :
<?php require __DIR__ . '/../vendor/autoload.php'; use Mithridatem\Routing\Loader\AttributeRouteLoader; use Mithridatem\Routing\Router; $router = new Router(); (new AttributeRouteLoader())->loadDirectory( router: $router, directory: __DIR__ . '/../src/Controller', namespace: 'App\\Controller' ); echo $router->dispatch();
Le dossier doit correspondre au namespace PSR-4. Par exemple,
src/Controller/Admin/DashboardController.php doit declarer
namespace App\Controller\Admin;.
Le chargement utilise l'autoload Composer et n'inclut pas directement les
fichiers trouves. Les classes non autoloadables sont ignorees.
Les classes abstraites (par exemple AbstractController) et les autres classes
non instanciables sont egalement ignorees lors de la decouverte du repertoire.
Les controleurs concrets peuvent donc heriter d'une base abstraite dans ce meme
dossier. Leurs methodes publiques heritees portant #[Route] restent chargees.
L'enregistrement explicite d'une classe non instanciable via loadController()
ou loadControllers() continue de lever une exception.
4. Autorisations et compatibilite
Les routes contenant grants sont refusees par defaut en l'absence de
droits configures. Pour cet exemple :
use Mithridatem\Routing\Auth\ArrayGrantChecker; $router->setGrantChecker(new ArrayGrantChecker(['ROLE_ADMIN']));
Vous pouvez toujours melanger decouverte automatique et declaration manuelle :
use Mithridatem\Routing\Route; $router->map(Route::get('/health', fn () => 'OK'));
5. Injection de dependances dans un controleur
La decouverte automatique enregistre les routes, mais elle ne construit
pas les controleurs avec leurs dependances. Le resolver par defaut appelle
new Controller() ; pour injecter des services, il faut fournir un
ControllerResolverInterface personnalise.
Cet exemple utilise un registre d'instances, sans dependre d'un framework ou d'un conteneur externe.
Service : src/Service/ProductService.php
<?php namespace App\Service; final class ProductService { public function findName(string $id): string { return 'Produit #' . $id; } }
Controleur : src/Controller/ProductController.php
<?php namespace App\Controller; use App\Service\ProductService; use Mithridatem\Routing\Attribute\Route; final class ProductController { public function __construct(private ProductService $products) { } #[Route(path: '/products/{id}', method: 'GET')] public function show(string $id): string { return $this->products->findName($id); } }
Resolver : src/Routing/InstanceControllerResolver.php
<?php namespace App\Routing; use Mithridatem\Routing\Controller\ControllerResolverInterface; use Mithridatem\Routing\Handler\ControllerReference; use Mithridatem\Routing\Exception\RouterException; final class InstanceControllerResolver implements ControllerResolverInterface { /** @param array<class-string, object> $controllers */ public function __construct(private array $controllers) { } public function resolve(ControllerReference $controller): callable { $class = $controller->getClassName(); $method = $controller->getMethodName(); $instance = $this->controllers[$class] ?? null; if ($instance === null) { throw new RouterException("Controleur non enregistre : $class"); } if (!is_callable([$instance, $method])) { throw new RouterException("Methode non accessible : $class::$method"); } return [$instance, $method]; } }
Point d'entree : public/index.php
<?php require __DIR__ . '/../vendor/autoload.php'; use App\Controller\ProductController; use App\Routing\InstanceControllerResolver; use App\Service\ProductService; use Mithridatem\Routing\Loader\AttributeRouteLoader; use Mithridatem\Routing\Router; $products = new ProductService(); $productController = new ProductController($products); $router = new Router(); $router->setControllerResolver(new InstanceControllerResolver([ ProductController::class => $productController, ])); (new AttributeRouteLoader())->loadDirectory( router: $router, directory: __DIR__ . '/../src/Controller', namespace: 'App\\Controller' ); echo $router->dispatch();
La route GET /products/42 renvoie Produit #42. La declaration
#[Route] est toujours decouverte automatiquement ; seule la creation
de l'instance du controleur est explicite. Les autres controleurs decouverts
doivent aussi etre enregistres dans ce resolver, sous peine d'erreur lors
du dispatch. Pour une application plus importante, remplacez le registre
par un resolver deleguant a un conteneur PSR-11.
Consultez egalement le guide detaille : Decouverte automatique des controleurs.
Grants d'authentification (optionnels)
Pour restreindre /login et /register aux visiteurs non connectes, ou proteger une route pour tous les utilisateurs authentifies, configurez AuthenticationAwareGrantChecker :
use Mithridatem\Routing\Auth\AuthenticationAwareGrantChecker; $router->setGrantChecker(new AuthenticationAwareGrantChecker( isAuthenticated: static fn (): bool => isset($_SESSION['user_id']) ));
Puis declarez grants: ['IS_GUEST'] ou grants: ['IS_AUTHENTICATED'] sur vos routes. Les grants de roles existants peuvent etre fournis via un second argument roleChecker et continuent a fonctionner en OU ; un grant d'authentification et les roles sont cumulatifs.
Important : la librairie ne gere ni session, ni JWT, ni redirection. Une route refusee leve toujours UnauthorizedException ; l'application Web gere eventuellement une redirection vers une page precedente valide, tandis que l'API renvoie une erreur JSON. Les anciens checkers conservent leur comportement.
Voir le guide des grants d'authentification.
Gestion des autorisations
Associez des droits a une route en fournissant un tableau de "grants" :
use Mithridatem\Routing\Auth\ArrayGrantChecker; $router->setGrantChecker(new ArrayGrantChecker(['ROLE_ADMIN'])); $router->map(Route::get('/admin', fn () => 'Admin panel', ['ROLE_ADMIN']));
Si l'utilisateur courant ne possede aucun des droits requis, une UnauthorizedException est levee. Branchez votre propre verificateur via GrantCheckerInterface pour relier vos mecanismes d'authentification.
Base path et sous-repertoires
Hebergez votre application derriere un prefixe sans re-ecrire vos routes :
$router->setBasePath('/mon-app');
Toutes les correspondances s'effectuent alors relativement a ce prefixe.
Gestion des erreurs
- Route introuvable :
RouteNotFoundException. - Acces refuse :
UnauthorizedException. - Erreur de resolution :
RouterException.
Capturez ces exceptions et adaptez la reponse HTTP de votre application.
Tests et exemples
composer install
composer test
Un exemple minimal est disponible dans examples/basic.php. Des doubles de tests se trouvent dans tests/Support pour simuler le contexte HTTP et les controlleurs.
Aller plus loin
- Documentation d'architecture :
docs/architecture.md. - Guide de migration :
docs/migration.md. - Processus de contribution :
CONTRIBUTING.mdetSECURITY.md.
Middleware pipeline (opt-in)
Le routeur prend en charge les middlewares globaux et propres aux routes :
$router->addMiddleware(new LoggingMiddleware()); #[Route(path: '/login', method: 'POST', middleware: [CsrfMiddleware::class])] public function login(): mixed { /* ... */ }
Le contrôle des grants existant reste actif, après les middlewares et avant le contrôleur. Les middlewares nécessitant des dépendances utilisent un MiddlewareResolverInterface configurable ; les implémentations CSRF/JWT restent dans l'application consommatrice.
Voir docs/middleware.md pour le contrat, la configuration et les notes de compatibilité.