memran / marwa-router
Attribute + fluent routing over league/route with PSR-7/15/16 support.
Installs: 4
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/memran/marwa-router
Requires
- php: ^8.1
- laminas/laminas-diactoros: ^3.0
- laminas/laminas-httphandlerrunner: ^2.6
- league/route: ^6.2
- memran/marwa-support: ^1.2
- psr/container: ^1.1 || ^2.0
- psr/simple-cache: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- memran/php-testify: ^1.0
- phpstan/phpstan: ^1.11
README
Attribute-driven routing and a fluent API on top of league/route.
- ✅ PHP 8 Attributes (native) —
#[Route],#[Prefix],#[UseMiddleware],#[GroupMiddleware],#[Where],#[Domain],#[Throttle] - ✅ Fluent manual routes (
$app->fluent()->get(...)->name(...)->middleware(...)->register()) - ✅ Optional trailing slash matching (
/fooand/foo/) - ✅ PSR-15 middlewares (class & method level)
- ✅ PSR-16 throttle middleware (Redis/Filesystem/Array cache)
- ✅ Domain binding and param constraints
- ✅ Custom Not Found handler
- ✅ Route registry &
bin/routes-dump - ✅ Framework-agnostic and PSR-7 compliant
- ✅ Works with Laminas Diactoros request objects
- ✅ Supports:
- Query parameters
- POST / JSON body
- Route attributes
- Uploaded files
- Cookies and headers
- Automatically falls back to PHP globals if not set manually
Install
composer require memran/marwa-router
Usage
Basic Setup
<?php declare(strict_types=1); use Marwa\Router\RouterFactory; use Laminas\Diactoros\Response\JsonResponse; use Psr\SimpleCache\CacheInterface; require __DIR__ . '/../vendor/autoload.php'; $app = new RouterFactory(); $app->fluent()->get('/', fn() => 'ok')->name('hello')->register(); // Fluent routes (optional) $app->fluent()->group(['prefix' => '/api', 'name' => 'api.'], function ($r) { $r->get('/hello', fn() => new JsonResponse(['hi' => 'there'])) ->name('hello') ->register(); }); // Attribute scan : optional (point this at your controllers folder) //$app->registerFromDirectories([__DIR__ . '/controllers'], strict: true); // Custom 404 (optional; strategy wraps strings/arrays accordingly) $app->setNotFoundHandler(fn($req) => '<h1>Oops!</h1><p>' . htmlspecialchars($req->getUri()->getPath()) . ' not found.</p>' ); $app->run();
Run a dev server:
php -S 127.0.0.1:8000 -t examples
Visit:
http://127.0.0.1:8000/api/hello
Any missing route returns your strategy’s 404.
Controller Example
<?php declare(strict_types=1); namespace Examples\Controllers; use Marwa\Router\Response; use Marwa\Router\Attributes\{Prefix, Route, UseMiddleware, GroupMiddleware, Where, Domain, Throttle}; use Psr\Http\Message\ServerRequestInterface; #[Prefix('/api/users', name: 'users.')] #[GroupMiddleware(\Examples\Middleware\ApiKeyMiddleware::class)] #[Where('id', '\d+')] #[Throttle(100, 60, 'ip')] // 100 requests per 60s per IP final class UserController { #[Route('GET', '', name: 'index')] public function index(): ResponseInterface { $input = new HttpRequest($request); return Response::json(['users' => $input->all()]); } #[UseMiddleware(\Examples\Middleware\TimingMiddleware::class)] #[Route('GET', '/{id}', name: 'show')] public function show(ServerRequestInterface $req): ResponseInterface { $input = Input::setRequest($request); return Response::json(['id' => Input::get('id')]); } }
Attributes
@Route
Define HTTP method and path for a controller method.
#[Route('GET', '', name: 'index')]
@RoutePrefix
Define a prefix for all routes in a controller.
#[Prefix('/api/users', name: 'users.')]
@Middleware
Apply middleware to controllers or methods.
#[Middleware(new AuthMiddleware())]
Custom 404 (HTML strategy shown):
$app->setNotFoundHandler(function ($req) { return '<h1>Not Found</h1><p>' . htmlspecialchars($req->getUri()->getPath()) . '</p>'; });
Tutorial: Fluent Routing
$app->fluent() ->group(['prefix' => '/api', 'name' => 'api.'], function ($r) { $r->get('/ping', fn() => 'pong') ->name('ping') ->middleware(\Examples\Middleware\TimingMiddleware::class) ->register(); $r->get('/users/{id}', [\Examples\Controllers\UserController::class, 'show']) ->where('id', '\d+') ->name('users.show') ->throttle(60, 60, 'ip') // 60/min per IP ->register(); });
Notes:
- ->register() finalizes each route in the fluent builder.
- ->where(), ->middleware(), ->throttle(), ->name() are chainable.
- Domain: ->domain('api.example.com').
Throttling (PSR-16)
Add #[Throttle(limit, seconds, key)] at class or method level, or ->throttle() in fluent routes. You must provide a PSR-16 cache to RouterFactory.
Examples:
- #[Throttle(100, 60, 'ip')] → 100/min per IP (uses client IP)
- #[Throttle(20, 60, 'X-API-Key')] → 20/min per API key (header)
- Fluent: ->throttle(60, 60, 'ip')
If you use throttle and forget to supply a cache, the router will throw at bootstrap.
Domain & Param Constraints
Attributes:
#[Domain('api.example.com')] #[Where('slug', '[a-z0-9-]+')] #[Route('GET', '/posts/{slug}', name: 'posts.show')]
Fluent:
$app->fluent()->get('/posts/{slug}', [Controller::class, 'show']) ->where('slug', '[a-z0-9-]+') ->domain('api.example.com') ->name('posts.show') ->register();
Route Dump (CLI)
A tiny CLI prints the effective route table.
php bin/routes-dump.php --dir=/absolute/path/to/examples/controllers
Windows:
php bin\routes-dump.php --dir="F:\projects\memran-marwa-router\examples\Controllers"
Alternatively, point it at a bootstrap that returns your configured RouterFactory:
php bin/routes-dump.php --bootstrap=examples/bootstrap.php
URL Generator
$urls = new \Marwa\Router\UrlGenerator($factory->routes()); $show = $urls->for('users.show', ['id' => 42]); // -> /api/users/42
Full Example: Minimal App
<?php declare(strict_types=1); require __DIR__ . '/../vendor/autoload.php'; use Marwa\Router\RouterFactory; $app = new RouterFactory(); // 1) Annotation scan (optional) $app->registerFromDirectories([__DIR__ . '/Controllers']); // Run app (reads globals, dispatches, emits) $app->run();
Get query or body parameters
Input::setRequest($request); $name = Input::get('name'); $email = Input::post('email'); $page = Input::query('page', 1); $all = Input::all();
Get route parameters (from League\Route)
$id = Input::route('id');
Get uploaded file
$file = Input::file('avatar');
Get cookie or header
$session = Input::cookie('session'); $agent = Input::header('User-Agent');
Get HTTP metadata
$method = Input::method(); // GET, POST, etc. $url = Input::url(); // Full URL
Custom Request
You can create a synthetic request easily using your own data:
use Marwa\Request\Http\RequestFactory; use Marwa\Request\Http\Input; $request = RequestFactory::fromArrays( server: ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/test'], query: ['q' => 'search'], parsedBody: ['name' => 'Emran'], cookies: ['session' => 'xyz'] ); Input::setRequest($request); echo Input::get('name'); // Emran echo Input::query('q'); // search
Troubleshooting
-
“No routes registered” in routes-dump Use absolute --dir paths or --bootstrap that returns $app. On Windows, path case/separators can differ; the included ClassLocator is Windows-safe.
-
#[Prefix] routes missing from dump This package registers prefixed routes eagerly (no lazy group closures), so they should show. If not, verify your controller namespace and that the file is inside the scanned dir.
-
Trailing slash quirks Optional trailing slash is enabled by default (e.g., /foo and /foo/). Disable with $app->setTrailingSlashOptional(false).
-
Throttle throws “CacheInterface not provided” Supply a PSR-16 cache to RouterFactory or remove the #[Throttle]/->throttle() usage.
License
MIT
Credits
Built on the excellent league/route and PSR ecosystem.