The compiled PHP 8 attribute router.
Declare routes with #[Route] attributes on controller methods. They are scanned
once and compiled into a static PHP dispatch table — zero runtime reflection,
zero external dependencies, PSR-7 compatible.
A lightweight option for small projects that want attribute routing without pulling in a framework or a larger routing component.
- Compiled — scanning and regex assembly happen once, at build time.
- Zero runtime reflection — dispatch is array lookups plus
preg_match. - Zero dependencies — only the PSR HTTP message interfaces.
- Minimal API — three classes you actually touch: the attribute, the CLI, the dispatcher.
- PHP 8.3+
- Any PSR-7 implementation for the request
object (e.g.
nyholm/psr7).
composer require kissous/attribute-routeruse Kissous\AttributeRouter\Attribute\Route;
final class UserController
{
#[Route('/users', method: 'GET', name: 'user.index')]
public function index(): string
{
return 'all users';
}
#[Route('/users/{id}', method: 'GET', name: 'user.show')]
public function show(string $id): string
{
return "user #{$id}";
}
}A parameter {id} matches a single path segment ([^/]+) and is passed to the
method by name.
vendor/bin/attribute-router compile --scan=src/Controller --out=var/routes.compiled.phpPass --scan more than once to scan several directories. Run this in your build
/ deploy pipeline (and locally after changing routes); the generated file is a
plain return [...] array that the dispatcher loads with require.
use Kissous\AttributeRouter\Dispatcher\Dispatcher;
$dispatcher = Dispatcher::fromCompiledFile(__DIR__ . '/var/routes.compiled.php');
$result = $dispatcher->dispatch($request); // any PSR-7 ServerRequestInterface
if ($result->isNotFound()) {
http_response_code(404);
return;
}
if ($result->isMethodNotAllowed()) {
http_response_code(405);
header('Allow: ' . implode(', ', $result->allowedMethods));
return;
}
// Matched: $result->controller, $result->action, $result->parameters, $result->name
$controller = new ($result->controller)();
echo $controller->{$result->action}(...$result->parameters);$result->parameters is keyed by parameter name, so spreading it (...) passes
them as named arguments — order-independent and matching the method signature.
Swap the new (...) line for your DI container when you have one.
Dispatcher::dispatch() returns an immutable DispatchResult:
| Property | Type | Meaning |
|---|---|---|
status |
DispatchStatus |
Found / NotFound / MethodNotAllowed |
controller |
?string |
matched controller FQCN (match only) |
action |
?string |
matched method name (match only) |
parameters |
array<string,string> |
captured route parameters (match only) |
name |
?string |
route name, if declared (match only) |
allowedMethods |
list<string> |
allowed methods (method-not-allowed only) |
Helpers: isFound(), isNotFound(), isMethodNotAllowed().
scan ──► compile ──► dispatch
ClassScanner reads #[Route] attributes via reflection and emits
ScannedRoute DTOs. Compiler turns them into a readable, closure-free PHP file
(static paths in a direct-lookup map, dynamic paths as anchored regexes).
Dispatcher loads that file and resolves a request with array lookups and
preg_match only. All reflection is confined to the scanner and runs at build
time. See docs/architecture.md.
Does it use reflection at runtime? No. Reflection is confined to the scanner and runs only when you compile.
What dependencies does it pull in? Only the PSR HTTP message interfaces.
Do I have to recompile after changing routes? Yes — re-run the compile
command. Wire it into your build step; in development, run it whenever routes
change.
Can I commit the compiled file? You can, but it is typically a build
artifact written to var/ and gitignored.
Is it a full framework / does it call my controller? No. It resolves a request to a controller, action and parameters; you instantiate and invoke (directly or through your container), keeping it framework-agnostic.
What about route constraints, #[Get] aliases, URL generation, middleware?
Planned for later minor versions — see the roadmap in docs/roadmap/.
The design trades a build step for a cheap runtime: no reflection, no attribute
parsing, no object graph — just a map lookup and, for dynamic routes, a single
preg_match. The aim is a small, dependency-free router that is easy to drop
into a small project, not a replacement for full-featured routers.
composer install
composer test # unit + integration
composer stan # PHPStan (max level)
composer cs:check # PHP-CS-Fixer dry-runMIT — see LICENSE.