A lightweight PSR-15 compatible PHP router with attribute-based routing, middleware pipeline, prefix-trie matching, and route caching.
Documentation: English | Русский
- PSR-15 compliant — implements
RequestHandlerInterface, works with any PSR-7 / PSR-15 stack - Attribute-based routing — declare routes with PHP 8
#[Route]attributes directly on controllers - Fast prefix-trie matching — static segments resolved via O(1) hash-map lookups; dynamic segments tested only when necessary
- Middleware pipeline — global and per-route PSR-15 middleware with FIFO execution
- Route groups — shared path prefixes and middleware for related routes
- Route caching — compile routes to a PHP file for OPcache-friendly production loading
- Automatic dependency injection — route parameters,
ServerRequestInterface, and container services injected into controller methods - URL generation — reverse routing from named routes and parameters
- Route diagnostics — detect duplicate paths, duplicate names, and shadowed routes
- Priority-based matching — control which route wins when patterns overlap
- PHP >= 8.4
ext-mbstring
composer require ascetic-soft/waypointuse AsceticSoft\Waypoint\Router;
use Nyholm\Psr7\ServerRequest;
$router = new Router($container); // any PSR-11 container
$router->get('/hello/{name}', function (string $name) use ($responseFactory) {
$response = $responseFactory->createResponse();
$response->getBody()->write("Hello, {$name}!");
return $response;
});
$request = new ServerRequest('GET', '/hello/world');
$response = $router->handle($request);Register routes with the fluent API. Shortcut methods are provided for common HTTP verbs:
// Full form
$router->addRoute('/users', [UserController::class, 'list'], methods: ['GET']);
// Shortcuts
$router->get('/users', [UserController::class, 'list']);
$router->post('/users', [UserController::class, 'create']);
$router->put('/users/{id}', [UserController::class, 'update']);
$router->delete('/users/{id}', [UserController::class, 'destroy']);
// Any other HTTP method (PATCH, OPTIONS, etc.)
$router->addRoute('/users/{id}', [UserController::class, 'patch'], methods: ['PATCH']);Each method accepts optional parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
$path |
string |
— | Route pattern (e.g. /users/{id}) |
$handler |
array|Closure |
— | [ClassName::class, 'method'] or a closure |
$middleware |
string[] |
[] |
Route-specific middleware class names |
$name |
string |
'' |
Optional route name |
$priority |
int |
0 |
Matching priority (higher = first) |
Parameters use FastRoute-style placeholders:
// Basic parameter — matches any non-slash segment
$router->get('/users/{id}', [UserController::class, 'show']);
// Constrained parameter — only digits
$router->get('/users/{id:\d+}', [UserController::class, 'show']);
// Multiple parameters
$router->get('/posts/{year:\d{4}}/{slug}', [PostController::class, 'show']);Parameters are automatically injected into the handler by name, with type coercion for scalar types:
$router->get('/users/{id:\d+}', function (int $id) {
// $id is automatically cast to int
});Declare routes directly on controller classes using the #[Route] attribute:
use AsceticSoft\Waypoint\Attribute\Route;
#[Route('/api/users', middleware: [AuthMiddleware::class])]
class UserController
{
#[Route('/', methods: ['GET'], name: 'users.list')]
public function list(): ResponseInterface { /* ... */ }
#[Route('/{id:\d+}', methods: ['GET'], name: 'users.show')]
public function show(int $id): ResponseInterface { /* ... */ }
#[Route('/', methods: ['POST'], name: 'users.create')]
public function create(ServerRequestInterface $request): ResponseInterface { /* ... */ }
#[Route('/{id:\d+}', methods: ['PUT'], name: 'users.update')]
public function update(int $id, ServerRequestInterface $request): ResponseInterface { /* ... */ }
#[Route('/{id:\d+}', methods: ['DELETE'], name: 'users.delete')]
public function delete(int $id): ResponseInterface { /* ... */ }
}The class-level #[Route] sets a path prefix and shared middleware. Method-level attributes define concrete routes. The attribute is repeatable, so a single method can handle multiple routes.
Loading attributes:
// Load specific controller classes
$router->loadAttributes(
UserController::class,
PostController::class,
);
// Or scan an entire directory
$router->scanDirectory(__DIR__ . '/Controllers', 'App\\Controllers');
// Optionally filter by filename pattern (e.g. only *Controller.php files)
$router->scanDirectory(__DIR__ . '/Controllers', 'App\\Controllers', '*Controller.php');| Parameter | Type | Default | Description |
|---|---|---|---|
$path |
string |
'' |
Path pattern (prefix on class, route on method) |
$methods |
string[] |
['GET'] |
HTTP methods (ignored on class-level) |
$name |
string |
'' |
Route name |
$middleware |
string[] |
[] |
Middleware (class-level prepended to method-level) |
$priority |
int |
0 |
Matching priority (higher = first) |
Group related routes under a shared prefix and middleware:
$router->group('/api', function (Router $router) {
$router->group('/v1', function (Router $router) {
$router->get('/users', [UserController::class, 'list']);
// Matches: /api/v1/users
});
$router->group('/v2', function (Router $router) {
$router->get('/users', [UserV2Controller::class, 'list']);
// Matches: /api/v2/users
});
}, middleware: [ApiAuthMiddleware::class]);Groups can be nested. Prefixes and middleware accumulate from outer to inner groups.
Waypoint supports PSR-15 middleware at two levels:
Global middleware — runs for every matched route:
$router->addMiddleware(CorsMiddleware::class);
$router->addMiddleware(new RateLimitMiddleware(limit: 100));Route-level middleware — applied to specific routes:
$router->get('/admin/dashboard', [AdminController::class, 'dashboard'],
middleware: [AdminAuthMiddleware::class],
);Middleware is resolved from the PSR-11 container when provided as a class name string, or used directly when provided as an instance. Execution order is FIFO: global middleware first, then route-specific middleware, then the controller handler.
The RouteHandler automatically resolves controller method parameters in the following order:
ServerRequestInterface— the current PSR-7 request- Route parameters — matched by parameter name, with type coercion (
int,float,bool) - Container services — resolved from the PSR-11 container by type-hint
- Default values — used if available
- Nullable parameters — receive
null
#[Route('/orders/{id:\d+}', methods: ['GET'])]
public function show(
int $id, // route parameter (auto-cast)
ServerRequestInterface $request, // current request
OrderRepository $repo, // resolved from container
?LoggerInterface $logger = null, // container or default
): ResponseInterface {
// ...
}Compile routes to a PHP file for zero-overhead loading in production:
// During deployment / cache warm-up
$router->compileTo(__DIR__ . '/cache/routes.php');// At runtime — load from cache
$cacheFile = __DIR__ . '/cache/routes.php';
$router = new Router($container);
if (file_exists($cacheFile)) {
$router->loadCache($cacheFile);
} else {
$router->scanDirectory(__DIR__ . '/Controllers', 'App\\Controllers');
$router->compileTo($cacheFile);
}The compiler generates a self-contained PHP class (Phase 3 format) with match expressions and pre-computed argument resolution plans. The resulting file loads through OPcache with zero overhead, bypassing all Reflection and attribute parsing at runtime. URL generation and route diagnostics work transparently with cached routes.
Inspect registered routes and detect potential issues:
use AsceticSoft\Waypoint\Diagnostic\RouteDiagnostics;
$diagnostics = new RouteDiagnostics($router->getRouteCollection());
// Print a formatted route table
$diagnostics->listRoutes();
// Detect conflicts
$report = $diagnostics->findConflicts();
if ($report->hasIssues()) {
$diagnostics->printReport();
}The diagnostic report detects:
- Duplicate paths — routes with identical patterns and overlapping HTTP methods
- Duplicate names — multiple routes sharing the same name
- Shadowed routes — a more general pattern registered earlier hides a more specific one
Generate URLs from named routes (reverse routing). Assign names when registering routes, then use generate() to build paths:
// Register named routes
$router->get('/users', [UserController::class, 'list'], name: 'users.list');
$router->get('/users/{id:\d+}', [UserController::class, 'show'], name: 'users.show');
// Generate URLs
$url = $router->generate('users.show', ['id' => 42]);
// => /users/42
$url = $router->generate('users.list', query: ['page' => 2, 'limit' => 10]);
// => /users?page=2&limit=10Parameters are automatically URL-encoded. Extra parameters not present in the route pattern are ignored. Missing required parameters throw MissingParametersException.
Set a base URL (scheme + host) to generate fully-qualified URLs:
$router->setBaseUrl('https://example.com');
$url = $router->generate('users.show', ['id' => 42], absolute: true);
// => https://example.com/users/42If absolute: true is used without a configured base URL, BaseUrlNotSetException is thrown.
You can also use the UrlGenerator directly:
use AsceticSoft\Waypoint\UrlGenerator;
$generator = new UrlGenerator($router->getRouteCollection(), 'https://example.com');
$url = $generator->generate('users.show', ['id' => 42]); // relative
$url = $generator->generate('users.show', ['id' => 42], absolute: true); // absoluteURL generation works with cached routes — route names and patterns are preserved in the cache file.
Waypoint throws specific exceptions for routing failures:
| Exception | HTTP Code | When |
|---|---|---|
RouteNotFoundException |
404 | No route pattern matches the URI |
MethodNotAllowedException |
405 | URI matches but HTTP method is not allowed |
RouteNameNotFoundException |
— | No route with the given name (URL generation) |
MissingParametersException |
— | Required route parameters not provided (URL generation) |
BaseUrlNotSetException |
— | Absolute URL requested but base URL not configured |
use AsceticSoft\Waypoint\Exception\RouteNotFoundException;
use AsceticSoft\Waypoint\Exception\MethodNotAllowedException;
try {
$response = $router->handle($request);
} catch (RouteNotFoundException $e) {
// Return 404 response
} catch (MethodNotAllowedException $e) {
// Return 405 response with Allow header
$allowed = implode(', ', $e->getAllowedMethods());
}HEAD → GET fallback: Per RFC 7231 §4.3.2, if no route explicitly handles HEAD but a GET route matches the same URI, the GET route is used automatically. No additional configuration is required.
Router (PSR-15 RequestHandlerInterface)
├── RouteCollection
│ ├── RouteTrie — prefix-tree for fast segment matching
│ └── Route[] — fallback linear matching for complex patterns
├── AttributeRouteLoader — reads #[Route] attributes via Reflection
├── MiddlewarePipeline — FIFO PSR-15 middleware execution
├── RouteHandler — invokes controller with DI
├── UrlGenerator — reverse routing (name + params → URL)
├── RouteCompiler — compiles/loads route cache
└── RouteDiagnostics — conflict detection and reporting
The RouteTrie handles the majority of routes with O(1) per-segment lookups. Routes with patterns that cannot be expressed in the trie (mixed static/parameter segments like prefix-{name}.txt, or cross-segment captures) automatically fall back to linear regex matching.
The project includes a Makefile with common tasks:
make fix # Auto-fix code style (PHP CS Fixer)
make cs-check # Check code style (dry-run)
make stan # Run PHPStan static analysis (level 9)
make test # Run PHPUnit tests
make check # Run all checks (cs-check + stan + test)
make all # Fix code style, then run stan and testsMIT