The HTTP-Routing component of the Snicco project is an opinionated
library that combines a routing system built upon FastRoute
with a power PSR-15
middleware dispatcher.
Although not a requirement, it was intentionally built to support legacy CMSs like WordPress where you don't have full control of the request-response lifecycle.
Features:
- Rich API to configure routes
- URL generation / reverse routing
- Attaching middleware on a per-route basis
- Route groups
- Completely cached in production
- Special handling for the admin area of a legacy CMS (if applicable)
- and much more.
composer require snicco/http-routing
The central class of the routing subcomponent is the Router
facade class. (no, not a laravel
facade)
The Router
serves as a factory for different parts of the routing system.
To instantiate a Router
we need the following collaborators:
- The
URLGenerationContext
, which is a value object that configures the URL generation. - A
RouteLoader
, which is responsible for loading and configuring your routes (only if nothing is cached yet.) - A
RouteCache
, which is responsible for caching the route definitions in production. - An instance of
AdminArea
, which serves as a bridge between the routing system and a legacy CMS admin area.
use Snicco\Component\HttpRouting\Routing\Cache\FileRouteCache;use Snicco\Component\HttpRouting\Routing\Cache\NullCache;
use Snicco\Component\HttpRouting\Routing\RouteLoader\DefaultRouteLoadingOptions;
use Snicco\Component\HttpRouting\Routing\RouteLoader\PHPFileRouteLoader;
use Snicco\Component\HttpRouting\Routing\Router;
use Snicco\Component\HttpRouting\Routing\UrlGenerator\UrlGenerationContext;
$context = new UrlGenerationContext('snicco.io');
$route_loading_options = new DefaultRouteLoadingOptions(
'/api/v1' // the base-prefix for API routes
);
$route_loader = new PHPFileRouteLoader(
[__DIR__.'/routes'], // directories of "normal" routes
[__DIR__.'/routes/api'], // directories of "API" routes, optional
$route_loading_options,
);
// during development
$route_cache = new NullCache();
// during production
$route_cache = new FileRouteCache('/path/to/cache_dir/route_cache.php');
$router = new Router(
$context,
$route_loader,
$route_cache
// $admin_area This is a simple interface that you can implement if you use admin routes.
);
Once we have our Router
, we can use it to instantiate the different parts of the routing
system.
use Snicco\Component\HttpRouting\Routing\Router;
/**
* @var Router $router
*/
$router = /* */
$router->routes(); // Returns an instance of RouteCollection
$router->urlGenerator(); // Returns an instance of UrlGenerator
$router->urlMatcher(); // Returns an instance of UrlMatcher
$router->adminMenu(); // Returns an instance of AdminMenu
The included PHPFileRouteLoader
will search for files with a .php
extension inside each of the provided route directories. Nested directories are not used.
For now, we assume the following directory structure:
your-project-root
├── routes/
│ ├── frontend.php
│ ├── admin.php
├── api-routes/
│ ├── v1.php
└── ...
Each file inside a route directory must return a closure that accepts an instance
of RoutingConfigurator
// ./routes/frontend.php
use Snicco\Component\HttpRouting\Routing\RoutingConfigurator\WebRoutingConfigurator;
return function (WebRoutingConfigurator $configurator ) {
//
}
A admin.php
route file is a special case. It will receive an instance
of AdminRoutingConfigurator
.
// ./routes/admin.php
use Snicco\Component\HttpRouting\Routing\RoutingConfigurator\AdminRoutingConfigurator;
return function (AdminRoutingConfigurator $configurator ) {
//
}
The RouteLoadingOptions
value object allows you to customize
some generic settings for all routes like automatically adding a middleware with the name of the
route file.
Check out the DefaultRouteLoadingOptions
for an example.
use Snicco\Component\HttpRouting\Routing\RoutingConfigurator\WebRoutingConfigurator;
/**
* @var WebRoutingConfigurator $configurator
*/
$configurator = /* */
$configurator->get(
'posts.index', // The route name MUST BE UNIQUE.
'/posts', // The route pattern
[PostController::class, 'index'] // The controller for the route.
);
$configurator->post('posts.create', '/posts', [PostController::class, 'create']);
$configurator->put('posts.update', '/posts/{post_id}', [PostController::class, 'update']);
$configurator->delete('posts.delete', '/posts/{post_id}', [PostController::class, 'delete']);
$configurator->patch(/* */);
$configurator->options(/* */);
$configurator->any(/* */);
$configurator->match(['GET', 'POST'], /* */);
The syntax of HTTP-Routing component offers an alternative syntax to the native syntax
of FastRoute
. This is highly opinionated, but we think that the
syntax of FastRoute
is a little to verbose, especially when
dealing with optional segments and regex requirements.
! For maximum performance, all routes will be compiled to match the native syntax of FastRoute before caching.
- Route segments enclosed within
{...}
are required. - Route segments enclosed within
{...?}
are optional.
$configurator->get(
'route_name',
'/posts/{post}/comments/{comment?}',
PostController::class
);
The above route definition will match /posts/1/comments/2
and /posts/1/comments
.
The captured parameters will be available to the configured controller.
Trailing slashes can be used in combination with route segments.
$configurator->get(
'route_name',
'/posts/{post}/comments/{comment?}/',
PostController::class
);
The above route definition will match /posts/1/comments/2/
and /posts/1/comments/
.
Optional segments can only occur at the end of a route pattern.
use Snicco\Component\HttpRouting\Routing\Route\Route;
use Snicco\Component\HttpRouting\Routing\RoutingConfigurator\WebRoutingConfigurator;
/**
* @var WebRoutingConfigurator $configurator
*/
$configurator->get(
'route1',
'/user/{id}/{name}',
PostController::class
)->requirements([
'id' => '[0-9]+',
'name' => '[a-z]+'
]);
/** @var Route $route */
$route = $configurator->get(/* */);
// The Route class contains a couple of helper methods.
$route->requireAlpha('segment_name');
$route->requireNum('segment_name');
$route->requireAlphaNum('segment_name');
$route->requireOneOf('segment_name', ['category-1', 'category-2']);
Middleware can be configured for each route individually.
A middleware can either be the fully qualified class name of a PSR-15 middleware or an alias that will later be resolved to the class name of a PSR-15 middleware.
Arguments can be passed to middleware (the constructor) as a comma separated list after a :
.
The following conversions are performed before instantiating a middleware with the passed arguments:
- (string) true => (bool) true
- (string) false => (bool) false
- (string) numeric => numeric
use Snicco\Component\HttpRouting\Routing\Route\Route;
use Snicco\Component\HttpRouting\Routing\RoutingConfigurator\WebRoutingConfigurator;
/**
* @var WebRoutingConfigurator $configurator
*/
$configurator->get('route1', '/route1', InvokableController::class)
// middleware as an alias.
->middleware('auth')
// adding multiple middleware
->middleware([PSR15MiddlewareOne::class, PSR15MiddlewareTwo::class]);
// passing comma separated arguments
->middleware('can:manage_options,1');
In addition to matching a route by its URL pattern, you can also specify route conditions.
A route condition is any class that implements RouteCondition
.
use Snicco\Component\HttpRouting\Routing\Route\Route;
use Snicco\Component\HttpRouting\Routing\RoutingConfigurator\WebRoutingConfigurator;
/**
* @var WebRoutingConfigurator $configurator
*/
$configurator->get('route1', '/route1', InvokableController::class)
->condition(OnlyIfUserAgentIsFirefox::class)
// passing arguments
->condition(OnlyIfHeaderIsPresent::class, 'X-CUSTOM-HEADER');
You can group routes with similar attributes together using route groups.
The following attributes can currently be grouped in some form:
- middleware: will be merged for all routes in the group.
- url prefix: will be added to all URL patterns in the group.
- route name: will be concatenated with a
.
for all routes. - namespace: will be set for all routes, but can be overwritten on a per-route basis.
Nested route groups are supported.
use Snicco\Component\HttpRouting\Routing\RoutingConfigurator\WebRoutingConfigurator;
/**
* @var WebRoutingConfigurator $configurator
*/
$configurator
->name('users')
->prefix('/base/users')
->middleware('auth')
->namespace('App\\Http\\Controllers')
->group(function (WebRoutingConfigurator $configurator) {
// The route name will be users.profile
// The route pattern will be /base/users/profile/{user_id}
// The controller definition will be [App\\Http\\Controllers\\ProfileController::class, 'index']
// The middleware is [auth, auth-confirmed]
$configurator->get('profile', '/profile/{user_id}', 'ProfileController@index')
->middleware('auth-confirmed');
$configurator->/* */->group(/* */);
});
The controller is the class method that is attached to route.
The controller will be used to transform a PSR-7 server request to a PSR-7 response. (more on that later)
For now, its only important how to define controllers and which arguments will be available in controllers.
namespace App\Controller;
use Snicco\Component\HttpRouting\Http\Psr7\Request;
class RouteController {
public function __invoke(Request $request){
//
}
public function withoutRequest(string $route_param){
//
}
public function withRequestTypehint(Request $request, string $route_param){
//
}
}
// Valid ways to define a controller:
$configurator->get('route1', '/route-1', RouteController::class)
$configurator->get('route2', '/route-2/{param}', [RouteController::class, 'withoutRequest']);
$configurator->get('route3', '/route3/{param}', 'App\\Controller\\RouteController@withRequestTypehint');
// or
$configurator->namespace('App\\Controller')->get('route3', '/route3/{param}', 'RouteController@withRequestTypehint');
If a controller is defined using the fully qualified class name it must have an __invoke
method.
It's possible to leave out the controller, in which case
a fallback controller will be added to the route. The fallback
controller will always return an instance of DelegatedResponse
which can be
used to express (to another system) that the current request should not be handled (by your code).
The first argument passed to all controller methods is an instance
of Snicco\Component\HttpRouting\Http\Psr7\Request
(if the controller method has that typehint).
Captured route segments are passed by order to the controller method. Method parameter names and segment names in the route definition are not important.
Captured route segments are always strings (in FastRoute), but numerical values are converted to integers for convenience.
Route conditions can also return "captured parameters". If a route has a condition that returned parameters, they will be passed to the controller methods after the parameters that were captured in the URL.
You can directly configure redirects in your route file. Instead of defining a dedicated controller
all redirect routes will use the RedirectController
to directly create
a RedirectResponse
.
use Snicco\Component\HttpRouting\Routing\RoutingConfigurator\WebRoutingConfigurator;
/**
* @var WebRoutingConfigurator $configurator
*/
$configurator->redirect('/foo', '/bar', 301);
$configurator->redirectAway('/foo', 'https://external-site.com', 302);
$configurator->redirectToRoute('/foo', 'route1', 307);
If you only want to return a simple template for a given URL without much logic
you can use the view()
method on the routing configurator.
use Snicco\Component\HttpRouting\Routing\RoutingConfigurator\WebRoutingConfigurator;
/**
* @var WebRoutingConfigurator $configurator
*/
$configurator->view('/contact', 'contact.php');
When this route matches it will return an instance of ViewResponse
.
It's up to you how to convert this into the underlying template. You will probably want to use your favorite
template engine inside a custom middleware to achieve this.
Routes defined in a admin.php
file are special in a sense that they can be used to create routes to the admin area of
a CMS like WordPress where you usually don't have control ofter the "routing".
You can even create admin menu items directly from your route definitions.
All of these implementation details are abstracted away by the AdminArea
interface
and the AdminMenu
interface.
Admin routes are configured by using
the AdminRoutingConfigurator
.
Admin routes are limited to GET
requests.
Instead of using the WebRoutingConfigurator::get()
method you'll use the AdminRoutingConfigurator::page()
and AdminRoutingConfigurator::subPage()
methods.
The following is an example on how you would use this in WordPress where routing in the admin area is done by using
a page
query variable. Check out the WPAdminArea
for the WordPress
implementation of the AdminArea
interface.
// .routes/admin.php
use Snicco\Component\HttpRouting\Routing\Admin\AdminMenu;
use Snicco\Component\HttpRouting\Routing\Admin\AdminMenuItem;
use Snicco\Component\HttpRouting\Routing\RoutingConfigurator\AdminRoutingConfigurator;
/**
* @var AdminRoutingConfigurator $configurator
*/
$configurator->name('my-plugin')
->middleware('my-plugin-middleware')
->group(function (AdminRoutingConfigurator $configurator) {
$parent_route = $configurator
->page('overview', '/admin.php/overview', OverViewController::class)
->middleware('parent-middleware');
$configurator->page(
'settings',
'/admin.php/settings',
SettingsController::class,
[
// Explicitly configure menu item attributes.
AdminMenuItem::MENU_TITLE => 'Custom settings title'
]
$parent_route // Set a parent route to create a menu hierarchy. Middleware is inherited.
);
});
In your default WordPress installation these routes would match the following path:
/wp-adming/admin.php?page=overview
/wp-adming/admin.php?page=settings
Based on your route name and route pattern an instance of AdminMenuItem
will
automatically be added to the AdminMenu
that
is available through the Router
.
The difference between route files inside the api route directory and "normal" routes is that
the RouteLoadingOptions::getApiRouteAttributes()
method will be used to apply default settings for each route.
This allows for example:
- adding a base prefix like
/api
to all routes - prefixing route names with
api.
- parsing a version number from the filename and applying it as a prefix
/api/v1
Using API-routes is completely optional.
Everything that was mentioned above will be cached in production into a single PHP file that can be returned very fast by OPcache.
For that exact reason this package intentionally does not support Closures
as a "route controller". Closures
can't
be serialized natively in PHP.
Internally, FastRoute
only contains the names of each route. Once a route is matched that single route only will be
hydrated and "run".
This provides a significant performance increase as the number of routes in your application grows.
Check out the SerializedRouteCollection
for details.
The first call to Router::urlMatcher()
will lazily load and configure all routes (or return the cached ones).
use Snicco\Component\HttpRouting\Routing\UrlMatcher\RoutingResult;
use Snicco\Component\HttpRouting\Routing\UrlMatcher\UrlMatcher;
$router = /* */
/** @var $url_matcher */
$url_matcher = $router->urlMatcher();
$psr_server_request = /* create any psr7 server request here. */
$routing_result = $url_matcher->dispatch($psr_server_request);
$routing_result->isMatch();
$routing_result->route();
$routing_result->decodedSegments();
Routing systems are always bidirectional:
- URL => Route
- route name + parameters => URL
FastRoute
only provides the first part. This package fills in that void.
The first call to Router::urlGenerator()
will lazily load and configure all routes (or return the cached ones).
Regex constraints are taken into account when generating URLs and provided values that would cause to not match the route will throw an exception.
use Snicco\Component\HttpRouting\Routing\Router;
use Snicco\Component\HttpRouting\Routing\UrlGenerator\UrlGenerator;
// In a route file:
$configurator->get('route1', '/route1/{param1}/{param2}', RouteController::class)
->requireAlpha('param1')
->requireNum('param2');
/**
* @var Router $router
*/
$router = /* */
$url_generator = $router->urlGenerator();
$url = $url_generator->toRoute('route1', ['param1' => 'foo', 'param2' => '1']);
var_dump($url); // /route1/foo/1
$url = $url_generator->toRoute('route1', ['param1' => 'foo', 'param2' => '1'], UrlGenerator::ABSOLUTE_URL);
var_dump($url); // https://snicco.io/route1/foo/1 (host and scheme depend on your UrlGenerationContext)
// This will throw an exception because param2 is not a number
$url_generator->toRoute('route1', ['param1' => 'foo', 'param2' => 'bar']);
If you are using admin routes, an instance of AdminMenu
will
automatically be configured based on your route definitions.
You can use the AdminMenu
object to configure some external system of a legacy
CMS (if applicable).
The first call to Router::adminMenu()
will lazily load and configure all routes (or return the cached ones).
/**
* @var Router $router
*/
$router = /* */
$admin_menu = $router->adminMenu();
foreach ($admin_menu->items() as $menu_item) {
// register the menu item somewhere.
}
This package comes with a very powerful PSR-15 middleware dispatcher that already incorporates the configured routing system.
The central piece is the MiddlewarePipeline
.
The middleware pipeline needs a PSR-11 container to lazily resolve your controllers and middleware.
Furthermore, an instance of HTTPErrorHanlder
is needed to handle
exceptions for each middleware.
use Psr\Container\ContainerInterface;
use Snicco\Component\HttpRouting\Middleware\MiddlewarePipeline;
use Snicco\Component\Psr7ErrorHandler\ProductionErrorHandler;
/**
* @var ContainerInterface $psr_11_container
*/
$psr_11_container = /* */
/**
* @var ProductionErrorHandler
*/
$psr7_error_handler = /* */
$pipeline = new MiddlewarePipeline(
$psr_11_container,
$psr7_error_handler
);
At a basic level, the middleware pipeline takes a PSR-7 server request, pipes it through multiple PSR-15 middleware and returns a PSR-7 response. How you send that response object is up to you.
use Snicco\Component\HttpRouting\Http\Psr7\Request;
use Snicco\Component\HttpRouting\Middleware\MiddlewarePipeline;
/**
* @var MiddlewarePipeline $pipeline
*/
$pipeline = /* */
$response = $pipeline
->send($server_request)
->through([
Psr15MiddlewareOne::class,
Psr15MiddlewareTwo::class,
])->then(function (Request $request) {
// Throw exception or return a default response.
throw new RuntimeException('Middleware pipeline exhausted without returning response.');
});
To connect the middleware pipeline with our routing system we use to inbuilt PSR-15 middleware of this package.
The RoutingMiddleware
is responsible for matching the current request in the
pipeline to a route of the routing system.
use Snicco\Component\HttpRouting\Middleware\RoutingMiddleware;
$routing_middleware = new RoutingMiddleware(
$router->urlMatcher();
);
The RouteRunner
is responsible for "running" the matched route.
If no route was matched an instance of DelegatedResponse
will be returned.
If a route was matched the following will happen:
- All middleware of the matched route will be resolved.
- A new (inner) middleware pipeline will be created that pipes the request through all route middleware.
- The last step of this inner middleware pipeline will resolve the route controller from the container and execute it.
To instantiate the RouteRunner
we first need
a MiddlewareResolver
.
use Snicco\Component\HttpRouting\Middleware\RouteRunner;
$pipeline = /* This can be the same pipeline we created initially. The pipeline is immutable anyway. */
$psr_11_container = /* */
$middleware_resolver = /* */
$route_runner = new RouteRunner($pipeline, $middleware_resolver, $psr_11_container);
$response = $pipeline->send($server_request)
->through([
$routing_middleware,
$route_runner
])->then(function () {
throw new RuntimeException('Middleware pipeline exhausted.');
});
As the class name suggests, the MiddlewareResolver
is responsible for
resolving all middleware that should be applied to an individual route and/or request.
use Snicco\Component\HttpRouting\Middleware\MiddlewareResolver;
// The following four middleware groups can be set to always be applied, even if no route matched.
$always_run = [
'global'
'frontend',
'admin',
'api'
]
// This configures the short aliases we used in our route definitions
$middleware_aliases = [
'auth' => AuthenticateMiddleware::class
]
// An alias can also be a middleware group.
// Middleware groups can contain other groups.
$middleware_groups = [
'group1' => [
'auth', // group contains alias
SomePsr15Middleware::class
],
'group2' => [
'group1,' // fully contains group1
SomeOtherPsr15Middleware::class
],
'global' => [],
'frontend' => [ ],
'api' => [
RateLimitAPI::class
],
'admin' => []
];
// A list of class names, the 0-index has the highest priority, meaning that it will
// always run first.
$middleware_priority = [
SomePsr15Middleware::class,
SomeOtherPsr15Middleware::class
];
$middleware_resolver = new MiddlewareResolver(
$always_run,
$middleware_aliases,
$middleware_groups,
$middleware_priority
);
The middleware resolver can be cached to maximize performance.
Caching the middleware resolver means, that for each routes in your application the middleware is already resolved recursively, groups are expanded, aliases are resolved etc.
use Snicco\Component\HttpRouting\Middleware\MiddlewareResolver;
$middleware_resolver = new MiddlewareResolver();
$store_me = $middleware_resolver->createMiddlewareCache(
$router->routes(),
$psr_11_container
);
file_put_contents('/path/to/cache-dir/middleware-cache.php', '<?php return ' . var_export($store_me, true) . ';');
list($route_map, $request_map) = require '/path/to/cache-dir/middleware-cache.php';
$cached_resolver = MiddlewareResolver::fromCache($route_map, $request_map);
This package contains some classes that extend the PSR interfaces to provide some utility helpers.
Using them is entirely optional:
- The abstract
Middleware
and the abstractController
can be extended. They both give you access to theResponseUtils
class and contain a reference to theURLGenerator
. - The
Request
class wraps any PSR-7 request and provides some helpful methods not defined in the PSR-7 interface. - The
Response
class wraps any PSR-7 response and provides some helpful methods not defined in the PSR-7 interface.
This repository is a read-only split of the development repo of the Snicco project.
This is how you can contribute.
Please report issues in the Snicco monorepo.
If you discover a security vulnerability, please follow our disclosure procedure.