inanepain / routing
HTTP Routing using attributes.
Requires
- php: >=8.5
- inanepain/http: >=0.4.0 || dev-master || dev-develop
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-13 01:11:34 UTC
README
Table of Contents
inanepain/routing- 1. Install
- 2. Overview of Attributes
- 3. The Router
- 4. The RouteMatch
- 5. Collecting Controllers
- 6. Usage
- Appendix: .htaccess
inanepain/routing
| Note |
Documentation updated for 1.5.0: the Route attribute, Router, RouteMatch and the new AttributeRouteCollector each have their own section.
|
HTTP Routing using attributes.
Add routing to your application by simply using attributes on your methods to configure the route.
In short: mark the controller methods you want reachable with the Route attribute, hand the controllers to the Router and ask it for a match.
1. Install
composercomposer require inanepain/routing
2. Overview of Attributes
What is an Attribute? It’s a class just like any other class only with the Attribute Attribute. So why are you treating it more like an enum or Map that can only hold a few values describing something? You don’t do it with the classes you uses your custom attributes on! But I don’t blame you, it all comes down to some pour choices in wording used by the documentation.
So how should I be think of Attributes? As classes naturally. Classes to object that get things done to be more exact. That #[Route(name: 'home', path: '/')] like might make more sense when you start looking at it like this: $route = new Route('/', 'home');. Here a fun experiment to try; remove the Attribute from Route then have the Router take an array of Route parameters as argument. Easy, wasn’t it and you understand Attributes and with practice you spot many more classes you can use as such.
Hope that gets you thinking about Attributes in a new, more realistic manor that leads to you adding that #[Attribute] line to a good many more classes.
2.1. The Route Attribute
Inane\Routing\Route targets methods and may be repeated per controller, one attribute per route.
| Parameter | Type | Default | Description |
|---|---|---|---|
|
|
required |
The url path. Placeholders are written |
|
|
|
Unique route name, used by |
|
|
|
A friendly name for the route, handy when building menus and links. |
|
|
all methods |
Http methods the route answers to. Strings are converted to |
|
|
|
Totally custom values attached to the route, read back with |
| Note |
Since 1.1.0 methods defaults to all http methods: GET, POST, PUT, DELETE, PATCH and OPTIONS.
|
use Inane\Http\HttpMethod; use Inane\Routing\Route; class BlogController { #[Route(path: '/blog', name: 'blog-index', label: 'Blog', methods: [HttpMethod::Get])] public function indexTask(): array { ... } // the `id` parameter only matches digits #[Route(path: '/blog/{id<\d+>}', name: 'blog-post', label: 'Article', extra: ['title' => 'Article {id}'])] public function postTask(): array { ... } }
2.1.1. Parameters
A placeholder without a regular expression falls back to Route::DEFAULT_REGEX ([\w\-\.]+), so it matches a single path segment of word characters, hyphens and periods.
-
hasParams()- does the path contain any placeholders. -
fetchParams()- placeholder names as keys with their regular expression (empty when the default applies) as value.
2.1.2. Extra information
Anything placed in extra is available through property(). The value is treated as a small template, meaning {name} tokens are replaced with the parameters supplied.
#[Route(path: '/blog/{id<\d+>}', name: 'blog-post', extra: ['title' => 'Article {id}'])] $route->property('title', ['id' => 7]); // Article 7 $route->property('missing'); // '' (empty string when not set)
3. The Router
Inane\Routing\Router holds the routes, matches them against the request and builds urls from them.
use Inane\Routing\Router; $router = new Router( routes: [MainController::class], // controllers and/or route configurations baseURI: '/app', // part of the uri to exclude when matching splitQuerystring: false, // separate the querystring from the path );
| Parameter | Type | Default | Description |
|---|---|---|---|
|
|
|
Controller class names carrying |
|
|
|
Part of the uri to strip before matching, useful when the project is served from a subfolder. Also prefixed onto generated urls. |
|
|
|
When |
3.1. Adding routes
Routes come from two sources, both handled by addRoutes(): controller classes using the Route attribute, and route configuration arrays keyed by the route name.
$router->addRoutes([ MainController::class, BlogController::class, 'backups' => [ 'route' => [ 'path' => '/archive/{section}/year/{id<\d+>}', 'methods' => ['GET'], ], 'class' => HistoryController::class, 'method' => 'list', ], ]);
| Caution |
An entry that is neither an existing class nor a configuration array raises an InvalidRouteException.
|
| Tip |
setBaseURI() sets the base uri after construction; both it and addRoutes() return the router so calls may be chained.
|
3.2. Matching a request
match() walks the routes in the order they were added and returns a RouteMatch for the first route whose path and http method satisfy the request, otherwise null. Pass a Inane\Http\Request to match something other than the current request.
if ($match = $router->match()) { $controller = new $match->class(); $controller->{$match->method}($match->params); }
3.3. Building urls
url() builds the path for a named route, filling in the placeholders from the parameters given and prefixing the base uri.
$router->url('blog-index'); // /blog $router->url('blog-post', ['id' => 7]); // /blog/7
Each value is checked against the placeholder’s regular expression, so:
-
an unknown route name throws an
OutOfRangeException; -
a missing parameter, or one that fails its regular expression, throws an
InvalidArgumentException.
3.4. Route properties
routeProperty() reaches the extra values of a named route, the parameters supplied fill out the value’s {name} tokens.
$router->routeProperty('blog-post', 'title', ['id' => 7]); // Article 7
3.5. Querystrings
With splitQuerystring enabled the querystring no longer takes part in the path matching and is instead parsed into the parameters under the query-string key.
// GET /search?q=robot&page=2 $router = new Router([SearchController::class], splitQuerystring: true); $match = $router->match(); $match->params['query-string']; // ['q' => 'robot', 'page' => '2']
3.6. Exceptions
All exceptions live in the Inane\Routing\Exception namespace.
| Exception | Thrown when |
|---|---|
|
A route entry is neither a known class nor a valid configuration. |
|
The requested route name is not registered. |
|
A route parameter is missing or does not match its regular expression. |
4. The RouteMatch
Since 1.4.0 Router::match() returns a Inane\Routing\RouteMatch describing the route that answered the request.
| Property | Type | Description |
|---|---|---|
|
|
Fully qualified controller class. |
|
|
Controller method to call. |
|
|
The uri used for matching, with the base uri already removed. |
|
|
Values of the path placeholders, plus |
|
|
The matched |
|
|
View template derived from the class and method names. |
if ($match = $router->match()) { $controller = new $match->class(); $data = $controller->{$match->method}($match->params); echo $renderer->render($match->template, $data); }
routeProperty() is a shortcut to the matched route’s extra information.
$match->routeProperty('title', $match->params);
4.1. The template
The template is worked out lazily by a parser function: the class' short name and the method name, joined with a directory separator. By default Controller is stripped from the class name and Task from the method name, so App\Controller\BlogController::postTask() becomes Blog/post.
Both suffixes are static properties and the whole parser may be replaced by passing a callable to the constructor.
Customising the templateuse Inane\Routing\RouteMatch; // change the stripped suffixes RouteMatch::$stripClass = 'Action'; RouteMatch::$stripMethod = 'Handler'; // or take over completely $match = new RouteMatch($route, static fn(string $class, string $method): string => "$class.$method");
5. Collecting Controllers
Listing every controller by hand grows tiresome. Inane\Routing\AttributeRouteCollector finds them for you by scanning the filesystem with a glob pattern and hands the result to a Router.
use Inane\File\Path; use Inane\Routing\AttributeRouteCollector; $collector = new AttributeRouteCollector([ 'splitQuerystring' => false, 'controller' => [ 'glob' => 'src/*/*Controller.php', 'glob_ignore' => '/(Abstract)/', 'default' => [WhoopsController::class], 'path' => new Path(__DIR__), ], ]); $router = $collector->createRouter();
5.1. Configuration
Options are merged over the defaults, so only the differences need to be given.
| Option | Default | Description |
|---|---|---|
|
|
Passed to the |
|
|
Glob pattern, relative to |
|
|
Regular expression tested against each filename; matches are skipped. |
|
|
Controllers and route configurations appended to the collected list; duplicates are removed. |
|
|
Search path for the glob, either a |
5.2. Methods
-
collect()- the controller classes and route configurations as anOptionscollection. -
collectInto(Router $router)- adds the collected routes to an existing router and returns it. -
createRouter(string $baseURI = '')- creates a router, honouringsplitQuerystring, populated with the collected routes.
$router = new Router([StaticController::class]); $collector->collectInto($router);
| Note | Classes are read from the matched files, so the files need only be reachable; the autoloader is left to load them when the routes are parsed. |
6. Usage
Quick overview showing the bits relating to the Route Attribute in three examples. Not all are complete, though the simple example would run with minimum fuss. Check the Appendix for the .htaccess file you will need to use with the index.php file.
6.1. Examples
An example is worth a thousand words, well here come three examples.
6.1.1. Example: Simple
Super simple example using php built in web server.
We create a class, let’s call it MainController.php, and add Route attributes to the methods we want routes to. The path is matched against the url with regex.
class MainController { ... #[Route(path: '/', name: 'home')] public function home(): void { ... echo <<<HTML ... HTML; } ... #[Route(path: '/product/{product}', name: 'product', )] public function productTask(array $params): void { $sql = "...where product_id = '{$params['product']}'"; ... echo <<<HTML ... HTML; } ... }
Our "application" sits in index.php which will pass through existing files and try route everything else.
We simply add our MainController to the Router and then check for a match.
use App\Controller\MainController; use Inane\Routing\Router; require_once 'vendor/autoload.php'; $file = 'public' . $_SERVER['REQUEST_URI']; // Server existing files in web dir if (file_exists($file) && !is_dir($file)) return false; $router = new Router(); // Add the controllers to the Router $router->addRoutes([MainController::class]); if ($match = $router->match()) { // Check for a Route Match // create the controller $controller = new $match->class(); // and call the method with parameters. $controller->{$match->method}($match->params); } else { // Otherwise do what ever else, we'll throw an error. throw new Exception('Request Error: Unmatched `file` or `route`!'); }
Now let’s fire up php’s built in server:
php built-in serverphp -S localhost:8080 -t public index.php
All that’s left is to visit the url in your favourite browser.
And that’s a real basic example of how it’s done and it doesn’t really get much more complex.
6.1.2. Example: Complete
Now let’s try a slightly more complex example.
The various pieces
Again we set up our routes by using attributes on the controller methods.
IndexController.phpclass IndexController extends AbstractController { ... #[Route(path: '/', name: 'home')] public function indexTask(): array { ... } ... #[Route(path: '/product/{product}', name: 'product', )] public function productTask(): array { ... } ... #[Route(path: '/product/{product}/review/{id<\d+>}', name: 'product-review')] public function reviewTask(): array { ... } ... }
But now we’re adding a view template to the mix. Not that this does much, but it’s just for show. So here we render an anchor.
index.phtml (view template)... <a class="menu-item" href="<?=$route->url('product', ['product' => $item['id']])?>"><?=$item['name_long']?></a> ...
That should give us this.
website (rendered view)<a class="menu-item" href="/product/mega-maid">Mega Maid (Household Robot Helper)</a>
Great.
Putting it all together
Chuck that all into an app, I’m only showing the parts relevant to the routing.
Application.phpclass Application { ... protected function initialise(): void { ... $this->router = new Router([ IndexController::class, ... WhoopsController::class, ... ]); ... } ... public function run(): void { ... if ($match = $this->router->match()) { $controller = new $match->class($match->params); $data = $controller->{$match->method}(); ... // since 1.4.0: using the RouteMatch we can now easily get the template $body = $this->renderer->render($match->template, $data); ... } ... } ... }
-
and you’re of to the races.
6.1.3. Example: Collected
The same application, only the controllers are no longer listed by hand; the AttributeRouteCollector goes looking for them.
use Inane\File\Path; use Inane\Routing\AttributeRouteCollector; class Application { ... protected function initialise(): void { ... $this->router = (new AttributeRouteCollector([ 'splitQuerystring' => true, 'controller' => [ // every *Controller.php one level down from src 'glob' => 'src/*/*Controller.php', // except the abstract ones 'glob_ignore' => '/(Abstract)/', // and these, wherever they may live 'default' => [WhoopsController::class], 'path' => new Path(dirname(__DIR__)), ], ]))->createRouter(); ... } ... public function run(): void { ... if ($match = $this->router->match()) { $controller = new $match->class($match->params); $data = $controller->{$match->method}(); ... $body = $this->renderer->render($match->template, $data); ... } ... } ... }
Drop a new controller into src, mark its methods with Route attributes and it is routed; no configuration to remember.
Appendix: .htaccess
You will also need to do some magic in your .htaccess file so that index.php handles all requests.
RewriteEngine On # The following rule tells Apache that if the requested filename exists, simply serve it. RewriteCond %{REQUEST_FILENAME} -s [OR] RewriteCond %{REQUEST_FILENAME} -l [OR] RewriteCond %{REQUEST_FILENAME} -d RewriteRule ^.*$ - [L] # The following rewrites all other queries to index.php. The # condition ensures that if you are using Apache aliases to do # mass virtual hosting or installed the project in a subdirectory, # the base path will be prepended to allow proper resolution of # the index.php file; it will work in non-aliased environments # as well, providing a safe, one-size fits all solution. RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ RewriteRule ^(.*) - [E=BASE:%1] RewriteRule ^(.*)$ %{ENV:BASE}/index.php [L] <Limit GET HEAD POST PUT DELETE OPTIONS> # Deprecated apache 2.2 syntax: # Order Allow,Deny # Allow from all # Apache > 2.4 requires: Require all granted </Limit>