Search by

mithridatem / router

mithridatem

Lightweight PHP routing library with configurable authorization and controller resolution.

v1.7.1 2026-11-07 16:15 UTC

This package is auto-updated.

Last update: 2026-11-07 16:29:49 UTC


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 (implementations AllowAllGrantChecker, ArrayGrantChecker ; le routeur utilise ArrayGrantChecker([]) 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_ADMIN et une route avec ROLE_USER autorise un utilisateur ayant uniquement ROLE_USER sur 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.md et SECURITY.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é.