hengeb / router
a PHP router that makes use of attributes
Requires
- php: >=8.0.0
- symfony/http-foundation: >=7.0
README
a PHP router that makes use of attributes and provides autowiring
example usage
Make sure your HTTP server redirects alle requests to index.php
(the front controller).
example nginx directive:
...
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
...
In index.php
, create the Router object and dispatch the request:
use App\Service\Router\Router; use Symfony\Component\HttpFoundation\Request; require_once '../vendor/autoload.php'; // create the Router object and pass it the directory with your controllers (will also look in subdirectories) $router = new Router(__DIR__ . '/../Classes/Controller'); $request = Request::createFromGlobals(); $router->dispatch($request)->send();
The router will look for a controller with a Route attribute that matches the router. The router will cache the routes and only re-analyze the controllers when a file has changed.
use Hengeb\Router\Attribute\Route; use Hengeb\Router\Attribute\AllowIf; use Hengeb\Router\Attribute\Inject; use Hengeb\Router\Attribute\PublicAccess; use Hengeb\Router\Attribute\RequestValue; use Hengeb\Router\Attribute\RequireLogin; use Hengeb\Router\Interface\CurrentUserInterface; use Hengeb\Db\Db; use Symfony\Component\HttpFoundation\Response; class MyController extends Controller { // the router will inject the TemplateEngine service directly after it created the object #[Inject] public TemplateEngine $templates; // the router will inject the Logger service when it creates the object public function __construct( private Logger $logger, ) {} // the route matcher starts with the HTTP method, followed by the path. You can use some regular expressions to some extend // do not use the ? symbol because this separates the query from the path) // this will match /search and / and also /search/ and also /search?foo=bar but not /search?q=theQuery because of the next route // note: one of the Access attributes is mandatory #[Route('GET /(search|)'), PublicAccess] public function form(): Response { return $this->render('SearchController/search'); } // you can use identifiers in the path or in the query, they will be passed as arguments and casted to the desired type // the Db object will be injected (in this case via the Db::getInstance() method) #[Route('GET /(search|)?q={query}', PublicAccess] public function search(string $query, Db $db): Response { $sql = $this->buildQuery($query); $results = $db->query($sql); return $this->showResults($ids); } // access control: only allow logged-in users // the dispatch method needs a second argument that implements the CurrentUserInterface #[Route('GET /users'), RequireLogin] public function users(): Response { ... } #[Route('GET /group/{group}'), RequireLogin] // the group object will be fetched from the database and injected public function show(Group $group): Response { ... } // you can have multiple route matchers, make sure they fit together // \d+ regex for number, get the user object by its id #[Route('GET /user/{\d+:id=>user}'), RequireLogin] // if the above matcher does not fit, get the user by its username #[Route('GET /user/{username=>user}'), RequireLogin] // the user object will be fetched from the database and injected public function show(User $user): Response { ... } // access control: only give access if the current user has the admin role OR is the user himself/herself // this will look for a method like hasRole, getRole, isRole, role, get("role") and so on // the '$user->get("id")' is a template string that will be evaluated. This does only allow simple function calls because the string will be parsed. #[Route('GET /user/{username=>user}/edit'), AllowIf(role: 'admin'), AllowIf(id: '$user->get("id")')] public function edit(User $user): Response { ... } // this route will only match if the method is POST // this will also check for a POST variable _csrfToken and validate it to prevent CSRF attacks // you can override this setting the CheckCsrfToken(false) attribute // generate the CSRF token with $router->createCsrfToken() // $username and $password will be injected from the request body #[Route('POST /login'), PublicAccess] public function login(#[RequestValue] string $username, #[RequestValue] string $password) { } // you can inject the router object, the request, the current user #[Route('GET /foo'), PublicAccess] public function foo(Request $request, CurrentUserInterface $user, Router $router) { ... } }
Model injection
You can tell the Router object how to retrieve an object of a given type:
$router->addType(User::class, fn($id) => User::find($id), 'id')
The third parameter is optional. Use it if there are multiple ways to find the target.
Alternatively your model can implement the RetrievabelModel interface that is part of this package.
class User implements RetrievableModel { public function retrieveModel($userId): ?static { return UserRepository::getInstance()->findOneById((int)$userId); } }
Service injection
You can tell the Router how to retrieve a service object:
$templateEngine = new TemplateEngine(); $router->addService(TemplateEngine::class, $templateEngine);
Or using a closure so objects will only be created when they are needed:
$router->addService(TemplateEngine::class, fn() => new TemplateEngine());
Exception handling
If something goes wrong a default error page will be shown with a short description of the error and the according HTTP status code.
You can add a method handleException(\Exception $e, ...)
(other dependcies will be injected) to your controller to handle the exception.
However, this will not work if no controller could be determined because no route matches request. To cover this case you can add a custom exception handler:
$router->addExceptionHandler(InvalidRouteException::class, [Controller::class, 'handleException']);
The object will be created with service injection.
Alternatively you can use a closure:
$router->addExceptionHandler(InvalidRouteException::class, fn(\Exception $e) => die($e->getMessage()));