aura/router
Aura\Router is a fast, standalone PHP routing library with PSR-7 support, named routes, route groups, and flexible matching for web apps and APIs.
Install the package and build a container once per request:
composer require aura/router
use Aura\Router\RouterContainer;
use Laminas\Diactoros\ServerRequestFactory;
$routerContainer = new RouterContainer();
$map = $routerContainer->getMap();
$map->get('home', '/', fn ($request) => 'home');
$map->get('post.show', '/posts/{id}', fn ($request) => 'post ' . $request->getAttribute('id'))
->tokens(['id' => '\d+']);
$request = ServerRequestFactory::fromGlobals();
$matcher = $routerContainer->getMatcher();
if ($route = $matcher->match($request)) {
// $route->handler, $route->params, $route->attributes
} else {
$failed = $matcher->getFailedRoute(); // ->failedRule, ->allows
}
Where to look first, in this order:
Aura\Router\RouterContainer — the only object you need; it hands out the map, matcher, and generator.Aura\Router\Map — get(), post(), route(), attach().Aura\Router\Route — tokens(), defaults(), host(), secure(), accepts(), allows().Aura\Router\Matcher — match() and getFailedRoute(); this is where your debugging starts.Aura\Router\Generator — generate() for URL building.First use case: a small standalone PSR-7 app, or a framework-agnostic package you want to work in any PSR-7 stack. The typical bootstrap is: route definitions in one file, ServerRequestFactory::fromGlobals() once, match() once, then hand $route + $request to your dispatcher. There is no framework magic — everything is explicit, which makes it very testable.
Named routes and URL generation — direct analogue of Laravel's route():
$generator = $routerContainer->getGenerator();
echo $generator->generate('post.show', ['id' => 42]); // /posts/42
Multi-method routes — like Laravel's Route::match():
$map->route('thing', '/things/{id}')
->allows(['GET', 'PATCH', 'DELETE']);
Groups — like Route::prefix()->name()->group(); the callback receives a group object with the same registration API:
$map->attach('admin.', '/admin', function ($group) {
$group->tokens(['id' => '\d+']); // applies to every route inside
$group->defaults(['area' => 'admin']); // merged into each route's defaults
$group->get('users', '/users', ...); // name: admin.users, path: /admin/users
});
Constraints and metadata — ->tokens([...]) is the ->where() equivalent; ->defaults([...]) attaches route metadata (controller/action/area) that your dispatcher can read off $route->attributes.
Request constraints beyond the path — this is where Aura earns its keep over FastRoute:
$map->get('api.things', '/things')
->host('api.example.com')
->secure()
->accepts(['application/json']);
Dispatching — Aura.Router never resolves handlers. Keep your own wiring or use a container:
$route = $matcher->match($request);
if ($route) {
$request = $request->withAttributes(
array_merge($request->getAttributes(), $route->attributes, $route->params)
);
$response = $container->call($route->handler, ['request' => $request]);
}
Proper 405s, which many home-grown routers skip:
if ($failed && $failed->failedRule === \Aura\Router\Route::FAILED_METHOD) {
return new TextResponse('Method Not Allowed', 405, [
'Allow' => implode(', ', $failed->allows),
]);
}
Use Route::FAILED_ACCEPTS for 406, Route::FAILED_AUTH for 401, Route::FAILED_SECURE for upgrade-to-HTTPS redirects.
Embedding in a Laravel app — don't fight Laravel's router. Register one catch-all and delegate a sub-tree:
// routes/web.php
Route::any('/legacy/{path?}', function (Illuminate\Http\Request $request) use ($routerContainer) {
$psrRequest = (new Psr17Factory())->createServerRequest(
$request->method(), $request->getRequestUri()
);
// match, dispatch, convert PSR-7 response back to a Laravel Response
})->where('path', '.*');
Testing — the biggest practical win: no globals, no container boot. Build a RouterContainer, construct a ServerRequest with an explicit method/URI/Accept header, assert on match() and getFailedRoute(). This makes a PSR-7 router far easier to unit-test than a global Laravel route collection.
Registering routes from a config array — for larger apps, iterate a PHP config and call $map->route($name, $path, $handler)->tokens(...); this is the closest thing you get to a route registry.
Aura\Dispatcher or your own PSR-11 container or you'll get stuck immediately.{id} implicit DI, no route caching, no session/CSRF/auth middleware, no php artisan route:list. Treat it as a matching engine, not a request lifecycle./foo does not match /foo/. Normalize in the request factory or register both.accepts() has a soft default. With no Accept header the rule usually passes as */*; test explicitly with and without the header before relying on it for API versioning.secure() behind a proxy. The check reads the request URI scheme. If you're behind a load balancer, make sure ServerRequestFactory::fromGlobals() (or your PSR-7 factory) reflects the forwarded scheme, or you'll get phantom failures in production only.defaults() is not Laravel's ->defaults(). It's per-route metadata merged into $route->attributes; nothing is auto-applied to a handler.HEAD and OPTIONS are not automatic. Define them explicitly, or intercept CORS preflight in a middleware layer before the matcher runs.Map object.admin.), so keep naming disciplined; there's no "last one wins" like some routers.$matcher->getFailedRoute()->failedRule tells you which rule killed the match (FAILED_METHOD, FAILED_ACCEPTS, FAILED_HOST, FAILED_TOKEN, FAILED_SECURE, FAILED_NOT_FOUND). Inspect the matcher's log (if present in your version) to trace every attempt.Aura\Web\Request and will not work with the examples above. Verify what Composer pulls in.Matcher, Rule\*, and Generator directly; you'll understand the whole package in an afternoon.RouterContainer, wrap the matching step in a PSR-15 middleware for cross-cutting concerns, and keep custom behaviour in your dispatcher rather than reaching into rule internals.How can I help you explore Laravel packages today?