monkeyscloud / monkeyslegion-router
Comprehensive attribute- and DSL-based HTTP router for MonkeysLegion with middleware, named routes, constraints, and caching
Installs: 487
Dependents: 4
Suggesters: 0
Security: 0
Stars: 0
Watchers: 1
Forks: 0
Open Issues: 0
pkg:composer/monkeyscloud/monkeyslegion-router
Requires
- php: ^8.4
- monkeyscloud/monkeyslegion-http: ^1.0
- psr/http-message: ^2.0
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-02-17 03:41:34 UTC
README
A comprehensive, production-grade HTTP router for PHP 8.4+ with PSR-15 middleware, attribute-based routing, resource CRUD shortcuts, and advanced dispatch features.
Features
Core Routing
✅ Attribute-Based Routing — PHP 8 attributes on controller methods
✅ Named Routes — URL generation from route names
✅ Route Constraints — Built-in + custom parameter validation
✅ Route Groups — Shared prefixes, middleware, and domain constraints
✅ Optional Parameters — Optional route segments
✅ Catch-All / Wildcard Routes — {path+} greedy parameter capture
✅ Method Handlers — get(), post(), put(), delete(), patch(), options()
Middleware (PSR-15)
✅ Dual Interface Support — Psr15MiddlewareInterface (new) + MiddlewareInterface (legacy callable $next)
✅ Priority-Based Ordering — Stable sort, higher priority runs first
✅ Legacy Adapter — v2.0 callable-style middleware auto-adapted transparently
✅ Parameterized Middleware — throttle:60,1 parsed automatically
✅ DI Container Support — Lazy middleware resolution via PSR-11
✅ CORS Middleware — Configurable origins, methods, headers, credentials
Dispatch Engine
✅ HEAD Auto-Delegation — HEAD automatically delegates to GET, strips body
✅ OPTIONS Auto-Response — Returns Allow header listing available methods
✅ Trailing-Slash Strategy — Configurable: STRIP, REDIRECT_301, ALLOW_BOTH
✅ Domain Constraints — Host/subdomain enforcement with pattern capture
✅ Fallback Handler — Catch-all for unmatched routes
✅ Redirect Routes — Convenience redirect() method
Developer Experience
✅ Resource Routes — resource() / apiResource() CRUD shortcuts
✅ Route Debugger — ASCII table listing with filtering
✅ Signed URLs — HMAC-signed URLs with optional expiration
✅ PSR-3 Logging — Automatic 404/405 logging via optional LoggerInterface
✅ Custom Error Handlers — Customizable 404 and 405 responses
✅ Route Caching — Compiled routes for production performance
✅ PSR-7 Compatible — Full PSR-7 HTTP message support
Installation
composer require monkeyscloud/monkeyslegion-router
Quick Start
Basic Routes
use MonkeysLegion\Router\Router; use MonkeysLegion\Router\RouteCollection; use Psr\Http\Message\ServerRequestInterface; $router = new Router(new RouteCollection()); // Simple GET route $router->get('/users', function (ServerRequestInterface $request) { return new Response( Stream::createFromString(json_encode(['users' => []])), 200, ['Content-Type' => 'application/json'] ); }); // Route with parameter $router->get('/users/{id}', function (ServerRequestInterface $request, string $id) { return new Response( Stream::createFromString("User ID: {$id}") ); }); // POST route $router->post('/users', function (ServerRequestInterface $request) { // Handle user creation });
Attribute-Based Controllers
use MonkeysLegion\Router\Attributes\Route; use MonkeysLegion\Router\Attributes\RoutePrefix; use MonkeysLegion\Router\Attributes\Middleware; #[RoutePrefix('/api/users')] #[Middleware(['cors', 'throttle'])] class UserController { #[Route('GET', '/', name: 'users.index')] public function index(ServerRequestInterface $request): Response { // List users } #[Route('GET', '/{id:\d+}', name: 'users.show')] public function show(ServerRequestInterface $request, string $id): Response { // Show user } #[Route('POST', '/', name: 'users.create')] #[Middleware('auth')] public function create(ServerRequestInterface $request): Response { // Create user } } // Register controller $router->registerController(new UserController());
Route Constraints
Built-in Constraints
// Integer constraint $router->get('/users/{id:\d+}', $handler); $router->get('/users/{id:int}', $handler); // Slug constraint $router->get('/posts/{slug:[a-z0-9-]+}', $handler); $router->get('/posts/{slug:slug}', $handler); // UUID constraint $router->get('/items/{uuid:uuid}', $handler); // Email constraint $router->get('/verify/{email:email}', $handler); // Numeric constraint $router->get('/price/{amount:numeric}', $handler); // Alphabetic constraint $router->get('/category/{name:alpha}', $handler); // Alphanumeric constraint $router->get('/code/{code:alphanum}', $handler);
Custom Constraints
use MonkeysLegion\Router\Constraints\RouteConstraintInterface; class DateConstraint implements RouteConstraintInterface { public function matches(string $value): bool { return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1; } public function getPattern(): string { return '\d{4}-\d{2}-\d{2}'; } } // Use custom regex directly $router->get('/archive/{date:\d{4}-\d{2}-\d{2}}', $handler);
Optional Parameters
// Optional page parameter $router->get('/posts/{page?}', function (ServerRequestInterface $request, ?string $page = '1') { // Handle pagination }); // Multiple optional parameters $router->get('/archive/{year}/{month?}/{day?}', $handler); // With constraints $router->get('/posts/{category}/{page:\d+?}', $handler);
Middleware
PSR-15 Middleware (v2.2+ — recommended)
New middleware should implement Psr15MiddlewareInterface:
use MonkeysLegion\Router\Middleware\Psr15MiddlewareInterface; use MonkeysLegion\Router\Middleware\RequestHandlerInterface; class AuthMiddleware implements Psr15MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { if (!$this->isAuthenticated($request)) { return new Response(Stream::createFromString('Unauthorized'), 401); } return $handler->handle($request); } } // Register by name $router->registerMiddleware('auth', AuthMiddleware::class);
Legacy Middleware (v2.0 — still fully supported)
Existing v2.0 middleware using callable $next implements the original MiddlewareInterface and is handled transparently by the pipeline:
use MonkeysLegion\Router\Middleware\MiddlewareInterface; // v2.0 middleware — still works without any changes class OldMiddleware implements MiddlewareInterface { public function process(ServerRequestInterface $request, callable $next): ResponseInterface { return $next($request); } } // Plain objects with a process(request, callable) method are also auto-adapted
Middleware Priority
use MonkeysLegion\Router\Middleware\MiddlewarePipeline; $pipeline = new MiddlewarePipeline(); $pipeline->pipe($corsMiddleware, 100); // Runs first (highest priority) $pipeline->pipe($authMiddleware, 50); $pipeline->pipe($loggingMiddleware, 10); // Runs last
Parameterized Middleware
// Middleware string with parameters: "throttle:60,1" // Parsed automatically — 60 requests per 1 minute $router->add('GET', '/api/data', $handler, middleware: ['throttle:60,1']);
DI Container Integration
// Set a PSR-11 container for lazy middleware resolution $router->setContainer($container); // Middleware registered as class-string is resolved via container $router->registerMiddleware('auth', AuthMiddleware::class);
Middleware Groups
$router->registerMiddlewareGroup('api', ['cors', 'throttle', 'json']); $router->registerMiddlewareGroup('web', ['cors', 'csrf']); $router->add('GET', '/api/users', $handler, 'users.index', ['api']);
Global Middleware
$router->addGlobalMiddleware('cors'); $router->addGlobalMiddleware('logging');
Route Middleware
$router->add('GET', '/admin', $handler, 'admin.dashboard', ['auth']); $router->add('POST', '/api/posts', $handler, 'posts.create', ['auth', 'throttle']); // With attributes #[Route('GET', '/admin', middleware: ['auth', 'admin'])] public function dashboard() { }
Route Groups
// Group with prefix $router->group(function (Router $router) { $router->get('/users', $usersHandler); $router->get('/posts', $postsHandler); }) ->prefix('/api/v1') ->middleware(['cors', 'throttle']) ->group(fn() => null); // Nested groups $router->group(function (Router $router) { $router->group(function (Router $router) { $router->get('/dashboard', $dashboardHandler); $router->get('/settings', $settingsHandler); }) ->middleware(['admin']) ->group(fn() => null); }) ->prefix('/admin') ->middleware(['auth']) ->group(fn() => null); // Routes will be: /admin/dashboard, /admin/settings // Middleware stack: ['auth', 'admin']
Named Routes & URL Generation
// Define named routes $router->get('/users', $handler, 'users.index'); $router->get('/users/{id}', $handler, 'users.show'); $router->get('/posts/{slug}', $handler, 'posts.show'); // Generate URLs $urlGen = $router->getUrlGenerator(); $urlGen->setBaseUrl('https://example.com'); echo $router->url('users.index'); // Output: /users echo $router->url('users.show', ['id' => 123]); // Output: /users/123 echo $router->url('users.show', ['id' => 123], true); // Output: https://example.com/users/123 // Extra parameters become query string echo $router->url('posts.show', ['slug' => 'hello', 'preview' => 1]); // Output: /posts/hello?preview=1
Route Caching
use MonkeysLegion\Router\RouteCache; $cache = new RouteCache(__DIR__ . '/cache'); $collection = new RouteCollection(); // Load from cache if available if ($cache->has()) { $data = $cache->load(); $collection->import($data); } else { // Register all routes $router = new Router($collection); // ... register routes ... // Save to cache $exported = $collection->export(); $cache->save($exported['routes'], $exported['namedRoutes']); } // Clear cache $cache->clear(); // Check cache stats $stats = $cache->getStats();
PSR-3 Logger Integration
The router can automatically log routing events when a PSR-3 compatible logger is provided:
use Psr\Log\LoggerInterface; // Any PSR-3 logger (MonkeysLegion Logger, Monolog, etc.) $router->setLogger($logger);
What gets logged:
| Event | Level | Context |
|---|---|---|
| Route not found (404) | notice |
method, path, uri |
| Method not allowed (405) | warning |
method, path, allowed_methods, uri |
Default error responses (when no custom handler is set) are now informative:
404 Not Found
The requested URL "/nonexistent" was not found on this server.
405 Method Not Allowed
The GET method is not allowed for "/api/users".
Allowed methods: POST, PUT
Custom Error Handlers
// Custom 404 handler $router->setNotFoundHandler(function (ServerRequestInterface $request) { return new Response( Stream::createFromString(json_encode(['error' => 'Not Found'])), 404, ['Content-Type' => 'application/json'] ); }); // Custom 405 handler $router->setMethodNotAllowedHandler( function (ServerRequestInterface $request, array $allowedMethods) { return new Response( Stream::createFromString(json_encode([ 'error' => 'Method Not Allowed', 'allowed' => $allowedMethods ])), 405, [ 'Content-Type' => 'application/json', 'Allow' => implode(', ', $allowedMethods) ] ); } );
Note: Logging happens before custom handlers are called, so you always get logs even when using custom error responses.
Built-in Middleware
CORS Middleware
use MonkeysLegion\Router\Middleware\CorsMiddleware; $cors = new CorsMiddleware([ 'allowed_origins' => ['https://example.com', 'https://app.example.com'], 'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], 'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'], 'exposed_headers' => ['X-Total-Count'], 'max_age' => 86400, 'credentials' => true, ]); $router->registerMiddleware('cors', $cors);
Throttle Middleware
use MonkeysLegion\Router\Middleware\ThrottleMiddleware; // 60 requests per minute $throttle = new ThrottleMiddleware(60, 1); $router->registerMiddleware('throttle', $throttle);
Advanced Features
All HTTP Methods
$router->get($path, $handler); // GET $router->post($path, $handler); // POST $router->put($path, $handler); // PUT $router->patch($path, $handler); // PATCH $router->delete($path, $handler); // DELETE $router->options($path, $handler); // OPTIONS // Multiple methods $router->match(['GET', 'POST'], $path, $handler); // Any method $router->any($path, $handler);
HEAD & OPTIONS Auto-Handling
// HEAD requests automatically delegate to the matching GET handler, // with the response body stripped (per RFC 7231) $router->get('/data', $handler); // HEAD /data → 200, empty body, same headers as GET // OPTIONS requests automatically return an Allow header // listing all methods registered for that path // OPTIONS /data → Allow: GET, HEAD, OPTIONS
Trailing-Slash Strategy
use MonkeysLegion\Router\TrailingSlashStrategy; // Default: strip trailing slashes (matches /users and /users/) $router->setTrailingSlashStrategy(TrailingSlashStrategy::STRIP); // 301 redirect from /users/ → /users $router->setTrailingSlashStrategy(TrailingSlashStrategy::REDIRECT_301); // Match both /users and /users/ without redirect $router->setTrailingSlashStrategy(TrailingSlashStrategy::ALLOW_BOTH);
Catch-All / Wildcard Routes
// {param+} captures everything including slashes $router->get('/files/{path+}', function ($request, string $path) { // GET /files/docs/readme.md → $path = "docs/readme.md" return serveFile($path); });
Domain / Host Constraints
// Literal domain $router->add('GET', '/dashboard', $handler, domain: 'admin.example.com'); // Pattern with parameter capture $router->add('GET', '/home', $handler, domain: '{tenant}.app.com'); // Matches: acme.app.com/home, corp.app.com/home, etc.
Fallback Handler
// Catch all unmatched routes (custom 404) $router->fallback(function ($request) { return new Response(Stream::createFromString('Page not found'), 404); });
Redirect Routes
// Convenience redirect $router->redirect('/old-page', '/new-page', 301); $router->redirect('/legacy', '/modern'); // 302 by default
Resource / CRUD Routes
// Full resource: index, create, store, show, edit, update, destroy $router->resource('/photos', new PhotoController()); // API-only: index, store, show, update, destroy (no create/edit forms) $router->apiResource('/photos', new PhotoController()); // Filter actions $router->resource('/photos', $ctrl)->only(['index', 'show']); $router->resource('/photos', $ctrl)->except(['destroy']);
Registered routes are automatically named: photos.index, photos.show, photos.store, etc.
Route Debugger
use MonkeysLegion\Router\RouteDebugger; $debugger = new RouteDebugger($router); // ASCII table output echo $debugger->render(); // +--------+-------------------+----------------------+------------+--------+ // | Method | URI | Name | Middleware | Domain | // +--------+-------------------+----------------------+------------+--------+ // | GET | / | home | | | // | GET | /users/{id} | users.show | auth | | // | POST | /api/users | api.users.store | auth, cors | | // +--------+-------------------+----------------------+------------+--------+ // Filter routes $debugger->filter(method: 'POST'); // Only POST routes $debugger->filter(pathContains: '/api'); // Routes containing /api $debugger->filter(name: 'users'); // Routes named *users* // Structured data $routes = $debugger->list(); // Array of route maps
Signed URLs
use MonkeysLegion\Router\SignedUrlGenerator; $signed = new SignedUrlGenerator($router->getUrlGenerator(), 'your-secret-key-here'); // Generate a signed URL (never expires) $url = $signed->generate('verify-email', ['id' => 42]); // → /verify-email/42?signature=abc123… // With expiration (1 hour) $url = $signed->generate('download', ['file' => 'report'], expiration: 3600); // → /download/report?expires=1700003600&signature=def456… // Validate $isValid = $signed->validate($urlFromRequest); // true/false
Dispatching Requests
use Psr\Http\Message\ServerRequestInterface; $request = /* PSR-7 ServerRequest */; $response = $router->dispatch($request); // Send response header('HTTP/1.1 ' . $response->getStatusCode()); foreach ($response->getHeaders() as $name => $values) { foreach ($values as $value) { header("$name: $value", false); } } echo $response->getBody();
Route Metadata
#[Route(
'GET',
'/users',
name: 'users.index',
summary: 'List all users',
description: 'Returns a paginated list of users',
tags: ['Users', 'API'],
meta: ['version' => '1.0', 'deprecated' => false]
)]
public function index() { }
Best Practices
- Use Route Caching in Production — Significantly improves performance
- Group Related Routes — Keep your routing organized
- Use Named Routes — Makes URL generation easier and refactoring safer
- Use PSR-15 Middleware — New code should use
Psr15MiddlewareInterfacewithRequestHandlerInterface; legacyMiddlewareInterface(callable $next) is still fully supported - Use Constraints — Validate parameters early in the request lifecycle
- Use Resource Routes —
resource()/apiResource()reduce boilerplate - Set a Trailing-Slash Strategy — Choose
STRIP,REDIRECT_301, orALLOW_BOTHglobally - Set Base URL — Configure the URL generator for absolute URL generation
Performance Tips
- Enable route caching in production environments
- Use specific HTTP methods instead of
any() - Order routes from most specific to least specific (done automatically)
- Use constraints to reduce regex complexity
- Minimize global middleware
- Use middleware priority to ensure expensive middleware runs only when needed
Requirements
- PHP 8.4 or higher
- PSR-7 HTTP Message implementation
- MonkeysLegion HTTP package
Testing
composer test # 53 tests, 99 assertions
License
MIT License. See LICENSE file for details.
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Support
For issues, questions, or contributions, please visit: https://github.com/MonkeysCloud/MonkeysLegion-Skeleton