memran / marwa-router
Attribute + fluent routing over league/route with PSR-7/15/16 support.
Requires
- php: ^8.2
- laminas/laminas-diactoros: ^3.0
- laminas/laminas-httphandlerrunner: ^2.6
- league/route: ^6.2
- psr/container: ^1.1 || ^2.0
- psr/log: ^1.1 || ^2.0 || ^3.0
- psr/simple-cache: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5 || ^11.5
README
Marwa Router is a framework-agnostic routing library for PHP 8.2+ built on top of league/route. It combines PHP 8 attributes, a fluent route builder, PSR-7 request handling, PSR-15 middleware integration, and small convenience helpers for responses, input access, route inspection, and signed URLs.
Why Marwa Router
- Keep route definitions close to controller code with native PHP attributes
- Register routes fluently when you want explicit bootstrap logic
- Stay compatible with PSR-7, PSR-15, PSR-16, and PSR-11 components
- Attach middleware, host constraints, parameter rules, and throttling per route or controller
- Use small utilities for JSON/HTML responses, request input, URL generation, and route inspection
Stability
This package follows semantic versioning for its documented public API under src/. Backward-compatible additions may appear in minor releases. Behavioral breaks, constructor signature changes, or renamed public methods belong in major releases and should be called out in CHANGELOG.md.
Features
- Attribute routing with
#[Route],#[Prefix],#[Where],#[Domain],#[UseMiddleware],#[GroupMiddleware], and#[Throttle] - Fluent route registration with grouping, naming, middleware, domain, constraints, and throttling
- Optional trailing-slash matching
- Direct route mapping with
map()and a fluent registrar for grouped definitions - PSR-11 container integration for controller and middleware resolution
- PSR-16-backed throttling middleware
- Optional PSR-3 logging hooks for dispatch failures and throttling events
- Trusted proxy and trusted host handling in
RequestFactory - Metadata cache and compiled route bootstrap cache for faster startup
- Response helpers for JSON, HTML, text, redirects, cookies, and downloads
- Input helpers for query params, parsed body, route params, headers, cookies, and files
- Route registry inspection with
bin/routes-dump.php - Signed URL generation and verification
Requirements
- PHP 8.2 or newer
- Composer
Installation
composer require memran/marwa-router
Quick Start
<?php declare(strict_types=1); use Marwa\Router\Response; use Marwa\Router\RouterFactory; require __DIR__ . '/vendor/autoload.php'; $router = new RouterFactory(); $router->fluent() ->get('/', fn () => Response::json(['ok' => true])) ->name('home'); $router->setNotFoundHandler(fn () => Response::text('Route Not Found', 404)); $router->run();
Start a local server:
php -S 127.0.0.1:8000 -t examples
Complete Tutorial
1. Bootstrap the Router
<?php declare(strict_types=1); use Marwa\Router\Response; use Marwa\Router\RouterFactory; require __DIR__ . '/../vendor/autoload.php'; $router = new RouterFactory(); $router->setTrailingSlashOptional(true); $router->setNotFoundHandler(fn () => Response::json(['message' => 'Not Found'], 404));
Use setContainer() when controllers or middleware should be resolved from a PSR-11 container. Use setCache() when throttling is enabled.
If your app runs behind a reverse proxy or load balancer, configure trust explicitly:
use Marwa\Router\Http\RequestFactory; RequestFactory::trustProxies(['127.0.0.1', '10.0.0.0/8']); RequestFactory::trustHosts(['example.com', '*.example.com']);
2. Register Attribute-Based Controllers
$router->registerFromDirectories([__DIR__ . '/../src/Controller'], strict: true);
strict: true is recommended in production so missing controller directories fail fast.
Example controller:
<?php namespace App\Controller; use Marwa\Router\Attributes\Prefix; use Marwa\Router\Attributes\Route; use Marwa\Router\Attributes\UseMiddleware; use Marwa\Router\Attributes\Where; use Marwa\Router\Response; use Psr\Http\Message\ResponseInterface; #[Prefix('/users', name: 'users.')] #[Where('id', '\d+')] final class UserController { #[Route('GET', '/', name: 'index')] public function index(): ResponseInterface { return Response::json(['users' => []]); } #[Route('GET', '/{id}', name: 'show')] #[UseMiddleware(\App\Middleware\AuditMiddleware::class)] public function show(): ResponseInterface { return Response::json(['user' => 'example']); } }
3. Add Fluent Routes
Use fluent routes for closures, bootstrap-only endpoints, or when you prefer explicit configuration.
Route definitions register automatically when the definition goes out of scope; ->register() is still available when you want to force registration immediately.
$router->fluent()->group(['prefix' => '/api', 'name' => 'api.'], function ($routes): void { $routes->get('/ping', fn () => Response::text('pong')) ->name('ping'); $routes->get('/posts/{slug}', [\App\Controller\PostController::class, 'show']) ->where('slug', '[a-z0-9-]+') ->name('posts.show'); });
Direct map() is also available when you want to register a route without the fluent builder:
$router->map( ['GET', 'HEAD'], '/health', static fn () => Response::json(['status' => 'ok']), name: 'health', );
4. Work with Middleware and Throttling
Per-route middleware can be attached with #[UseMiddleware], #[GroupMiddleware], or ->middleware(...).
If you use throttling, provide a PSR-16 cache:
$router = new RouterFactory(cache: $cache);
Attribute example:
use Marwa\Router\Attributes\Throttle; #[Throttle(100, 60, 'ip')] final class ApiController { #[Route('GET', '/stats', name: 'stats')] public function stats(): ResponseInterface { return Response::json(['ok' => true]); } }
Fluent example:
$router->fluent() ->post('/api/login', [AuthController::class, 'login']) ->throttle(10, 60, 'ip') ->name('api.login');
Included middleware classes live in src/Middleware/:
AuthTokenMiddlewareBodyParsingMiddlewareContentTypeMiddlewareCorsMiddlewareCsrfMiddlewareExceptionToResponseMiddlewareMaintenanceModeMiddlewareRequestIdMiddlewareRequestGuardMiddlewareSecurityHeadersMiddlewareThrottleMiddleware
5. Read Input Data
Marwa\Router\Http\Input and Marwa\Router\Http\HttpRequest provide ergonomic access to PSR-7 request data.
use Marwa\Router\Http\Input; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; public function search(ServerRequestInterface $request): ResponseInterface { Input::setRequest($request); return Response::json([ 'q' => Input::query('q'), 'page' => Input::query('page', 1), 'filters' => Input::only(['category', 'status']), ]); }
Available helpers include get(), post(), query(), route(), header(), cookie(), file(), only(), except(), has(), and merge().
6. Generate URLs
The router keeps a route registry that can be passed to UrlGenerator.
$urls = new \Marwa\Router\UrlGenerator($router->routes()); $show = $urls->for('users.show', ['id' => 42]); $signed = $urls->signed('users.show', ['id' => 42], 300, 'app-secret'); $valid = $urls->verify($signed, 'app-secret');
6.1 Attach a Logger
Provide any PSR-3 logger if you want visibility into missing routes or throttle violations.
$router->setLogger($logger);
7. Run the Application
$router->run();
If you need a response object without emitting it immediately, use handle():
$request = \Marwa\Router\Http\RequestFactory::fromGlobals(); $response = $router->handle($request);
For local experimentation, the repository already includes a runnable example:
php -S 127.0.0.1:8000 -t examples
8. Inspect Registered Routes
Print the route table discovered from controllers:
php bin/routes-dump.php --dir=/absolute/path/to/src/Controller
Or point the CLI to a bootstrap file that returns a configured RouterFactory instance:
php bin/routes-dump.php --bootstrap=/absolute/path/to/bootstrap.php
9. Export the Route Registry
php bin/routes-build-cache.php
This writes:
var/cache/routes.phpwith route metadatavar/cache/routes.compiled.phpwith a bootstrap callable that re-registers cacheable routes without rescanning controller files
Compiled route cache supports string handlers, [class-string, method] handlers, and middleware defined as class strings. It does not support closures or object middleware.
More Examples
Attribute Examples
Controller-level host binding and middleware:
use Marwa\Router\Attributes\Domain; use Marwa\Router\Attributes\GroupMiddleware; use Marwa\Router\Attributes\Prefix; use Marwa\Router\Attributes\Route; #[Prefix('/admin', name: 'admin.')] #[Domain('admin.example.com')] #[GroupMiddleware(\App\Middleware\AdminAuthMiddleware::class)] final class AdminController { #[Route('GET', '/dashboard', name: 'dashboard')] public function dashboard(): \Psr\Http\Message\ResponseInterface { return \Marwa\Router\Response::html('<h1>Admin</h1>'); } }
Single method responding to multiple HTTP verbs:
#[Route(['GET', 'POST'], '/contact', name: 'contact.submit')] public function contact(): \Psr\Http\Message\ResponseInterface { return \Marwa\Router\Response::text('Handled'); }
Fluent Route Examples
Named route with middleware and domain:
$router->fluent() ->get('/reports/{year}', [ReportController::class, 'show']) ->where('year', '\d{4}') ->domain('reports.example.com') ->middleware(\App\Middleware\AuditMiddleware::class) ->name('reports.show');
Route group with shared prefix, name prefix, and throttling:
$router->fluent()->group([ 'prefix' => '/api/v1', 'name' => 'api.v1.', 'throttle' => ['limit' => 60, 'per' => 60, 'key' => 'ip'], ], function ($routes): void { $routes->get('/users', [UserController::class, 'index']) ->name('users.index'); });
Direct map() with middleware, constraints, and a name:
$router->map( 'GET', '/reports/{year}', [ReportController::class, 'show'], name: 'reports.show', middlewares: [\App\Middleware\AuditMiddleware::class], where: ['year' => '\d{4}'], );
Request Access Examples
Using HttpRequest directly:
use Marwa\Router\Http\HttpRequest; use Psr\Http\Message\ServerRequestInterface; public function store(ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface { $input = new HttpRequest($request); return \Marwa\Router\Response::json([ 'method' => $input->method(), 'url' => $input->url(), 'host' => $input->host(), 'subdomain' => $input->subdomainFor('example.com'), 'all' => $input->all(), 'only' => $input->only(['name', 'email']), 'except' => $input->except(['password']), 'route' => $input->routeParams(), 'agent' => $input->header('User-Agent'), ]); }
Using the static Input facade:
use Marwa\Router\Http\Input; Input::setRequest($request); $email = Input::post('email'); $search = Input::query('q'); $token = Input::header('X-Token'); $avatar = Input::file('avatar'); $host = Input::host(); $tenant = Input::subdomainFor('example.com'); $hasFilters = Input::has('filters.status'); Input::merge(['normalized' => true]);
Use subdomainFor() when your application knows its base domain. It is deterministic for hosts like tenant.example.com and admin.eu.example.co.uk.
Resetting the static facade in tests:
Input::reset();
Typed Bag and Form Request Examples
Using InputBag accessors:
$body = new \Marwa\Router\Http\InputBag([ 'page' => '2', 'active' => 'true', 'filters' => ['role' => 'editor'], ]); $page = $body->int('page'); $active = $body->bool('active'); $filters = $body->array('filters');
Minimal FormRequest subclass:
use Marwa\Router\Http\FormRequest; final class CreateUserRequest extends FormRequest { public function rules(): array { return [ 'name' => ['required'], 'email' => ['required'], ]; } }
Accessing data:
$form = new CreateUserRequest($request, $validator); if (!$form->authorize()) { throw new RuntimeException('Forbidden'); } $query = $form->query()->string('q'); $name = $form->body()->string('name'); $validated = $form->validate();
File Upload Example
use Laminas\Diactoros\UploadedFile; use Marwa\Router\Http\Input; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; public function uploadAvatar(ServerRequestInterface $request): ResponseInterface { Input::setRequest($request); /** @var UploadedFile|null $avatar */ $avatar = Input::file('avatar'); if ($avatar === null || $avatar->getError() !== UPLOAD_ERR_OK) { return \Marwa\Router\Response::error('Upload failed', 400); } $target = __DIR__ . '/../storage/' . $avatar->getClientFilename(); $avatar->moveTo($target); return \Marwa\Router\Response::success(['path' => $target], 'Uploaded'); }
Custom Middleware Example
use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; final class ApiKeyMiddleware implements MiddlewareInterface { public function __construct( private string $header = 'X-API-Key', private string $expected = 'secret', ) {} public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { if ($request->getHeaderLine($this->header) !== $this->expected) { return new JsonResponse(['error' => 'Unauthorized'], 401); } return $handler->handle($request); } }
Use it with attributes or fluent routes:
#[UseMiddleware(ApiKeyMiddleware::class)] #[Route('POST', '/internal/rebuild', name: 'internal.rebuild')] public function rebuild(): ResponseInterface { return \Marwa\Router\Response::text('ok'); }
Built-in middleware can be attached the same way:
$router->map( 'POST', '/api/users', [UserController::class, 'store'], middlewares: [ \Marwa\Router\Middleware\BodyParsingMiddleware::class, \Marwa\Router\Middleware\SecurityHeadersMiddleware::class, \Marwa\Router\Middleware\RequestGuardMiddleware::class, ], );
Container Integration Example
Any PSR-11 container works. One option is league/container:
use League\Container\Container; use Marwa\Router\RouterFactory; $container = new Container(); $container->add(\App\Service\UserService::class); $container->add(\App\Controller\UserController::class) ->addArgument(\App\Service\UserService::class); $container->add(\App\Middleware\ApiKeyMiddleware::class); $router = new RouterFactory(); $router->setContainer($container); $router->registerFromDirectories([__DIR__ . '/../src/Controller'], strict: true);
Once a container is attached, controller classes and middleware class names are resolved through it before falling back to direct instantiation.
Signed Download Example
$urls = new \Marwa\Router\UrlGenerator($router->routes()); $downloadUrl = $urls->signed('reports.download', ['id' => 25], 300, $_ENV['APP_KEY']);
Controller:
use Marwa\Router\UrlGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; public function download(ServerRequestInterface $request, UrlGenerator $urls): ResponseInterface { $url = (string) $request->getUri(); if (!$urls->verify($url, $_ENV['APP_KEY'])) { return \Marwa\Router\Response::forbidden('Invalid or expired signature'); } return \Marwa\Router\Response::download(__DIR__ . '/../reports/report-25.csv'); }
Mini CRUD Example
$router->fluent()->group(['prefix' => '/api/users', 'name' => 'users.'], function ($routes): void { $routes->get('/', [UserController::class, 'index']) ->name('index') ->register(); $routes->post('/', [UserController::class, 'store']) ->middleware(\App\Middleware\ApiKeyMiddleware::class) ->name('store') ->register(); $routes->get('/{id}', [UserController::class, 'show']) ->where('id', '\d+') ->name('show') ->register(); $routes->patch('/{id}', [UserController::class, 'update']) ->where('id', '\d+') ->name('update') ->register(); $routes->delete('/{id}', [UserController::class, 'delete']) ->where('id', '\d+') ->name('delete') ->register(); });
Possible controller methods:
final class UserController { public function index(): \Psr\Http\Message\ResponseInterface { return \Marwa\Router\Response::json(['data' => []]); } public function store(ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface { \Marwa\Router\Http\Input::setRequest($request); return \Marwa\Router\Response::created([ 'name' => \Marwa\Router\Http\Input::post('name'), 'email' => \Marwa\Router\Http\Input::post('email'), ]); } public function show(ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface { $input = new \Marwa\Router\Http\HttpRequest($request); return \Marwa\Router\Response::json(['id' => $input->route('id')]); } }
Pagination Example
use Marwa\Router\Http\Input; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; public function index(ServerRequestInterface $request): ResponseInterface { Input::setRequest($request); $page = max(1, (int) Input::query('page', 1)); $perPage = min(100, max(1, (int) Input::query('per_page', 20))); return \Marwa\Router\Response::json([ 'meta' => [ 'page' => $page, 'per_page' => $perPage, ], 'data' => [], ]); }
JSON API Error Example
return \Marwa\Router\Response::error( 'Validation failed', 422, [ 'email' => ['The email field is required.'], 'password' => ['The password must be at least 12 characters.'], ], );
Or with a custom structure:
return \Marwa\Router\Response::json([ 'errors' => [ ['status' => '422', 'detail' => 'The email field is required.'], ['status' => '422', 'detail' => 'The password must be at least 12 characters.'], ], ], 422);
Subdomain Routing Example
Attribute-based host binding:
use Marwa\Router\Attributes\Domain; use Marwa\Router\Attributes\Route; #[Domain('api.example.com')] final class ApiStatusController { #[Route('GET', '/status', name: 'api.status')] public function status(): \Psr\Http\Message\ResponseInterface { return \Marwa\Router\Response::json(['ok' => true]); } }
Fluent host binding:
$router->fluent() ->get('/status', [StatusController::class, 'show']) ->domain('status.example.com') ->name('status.show') ->register();
Reading the current host in middleware:
final class TenantMiddleware implements \Psr\Http\Server\MiddlewareInterface { public function process( \Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler, ): \Psr\Http\Message\ResponseInterface { $input = new \Marwa\Router\Http\HttpRequest($request); return $handler->handle( $request->withAttribute('tenant', $input->subdomainFor('example.com')) ); } }
Reading it later in a controller:
public function dashboard(\Psr\Http\Message\ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface { return \Marwa\Router\Response::json([ 'host' => $request->getUri()->getHost(), 'tenant' => $request->getAttribute('tenant'), ]); }
If your app runs on more than one root domain, inject that base domain into middleware from configuration and call subdomainFor($configuredBaseDomain).
Webhook Verification Example
use Marwa\Router\Http\HttpRequest; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; public function handleWebhook(ServerRequestInterface $request): ResponseInterface { $input = new HttpRequest($request); $payload = (string) $request->getBody(); $signature = (string) $input->header('X-Signature', ''); $expected = hash_hmac('sha256', $payload, $_ENV['WEBHOOK_SECRET']); if (!hash_equals($expected, $signature)) { return \Marwa\Router\Response::forbidden('Invalid webhook signature'); } return \Marwa\Router\Response::noContent(); }
Authenticated Admin Area Example
$router->fluent()->group([ 'prefix' => '/admin', 'name' => 'admin.', 'middleware' => [\App\Middleware\AdminAuthMiddleware::class], ], function ($routes): void { $routes->get('/dashboard', [AdminController::class, 'dashboard']) ->name('dashboard') ->register(); $routes->get('/users', [AdminUserController::class, 'index']) ->name('users.index') ->register(); });
Controller-level version:
use Marwa\Router\Attributes\GroupMiddleware; use Marwa\Router\Attributes\Prefix; #[Prefix('/admin', name: 'admin.')] #[GroupMiddleware(\App\Middleware\AdminAuthMiddleware::class)] final class AdminController { #[Route('GET', '/dashboard', name: 'dashboard')] public function dashboard(): \Psr\Http\Message\ResponseInterface { return \Marwa\Router\Response::html('<h1>Dashboard</h1>'); } }
PHPUnit Usage Example
use Marwa\Router\Http\RequestFactory; use Marwa\Router\UrlGenerator; use PHPUnit\Framework\TestCase; final class UrlGeneratorTest extends TestCase { public function testSignedUrlCanBeVerified(): void { $generator = new UrlGenerator([ ['name' => 'users.show', 'path' => '/users/{id}'], ]); $signed = $generator->signed('users.show', ['id' => 42], 300, 'secret'); self::assertTrue($generator->verify($signed, 'secret')); } public function testRequestFactoryBuildsQueryParams(): void { $request = RequestFactory::fromArrays( server: ['REQUEST_URI' => '/users?page=2', 'REQUEST_METHOD' => 'GET'], query: ['page' => 2], ); self::assertSame(2, $request->getQueryParams()['page']); } }
Response Helpers
Marwa\Router\Response exposes small factory helpers:
Response::json(...)Response::html(...)Response::text(...)Response::redirect(...)Response::download(...)Response::success(...)Response::error(...)Response::notFound(...)Response::serverError(...)Response::unauthorized(...)Response::forbidden(...)Response::created(...)Response::noContent()Response::fromArray(...)
Example:
return Response::success(['id' => 42], 'Created', 201);
Additional examples:
return Response::json(['status' => 'ok']); return Response::html('<p>Hello</p>'); return Response::text('Accepted', 202); return Response::redirect('/login'); return Response::download(__DIR__ . '/report.csv'); return Response::error('Validation failed', 422, ['email' => ['Required']]); return Response::notFound(); return Response::unauthorized(); return Response::forbidden(); return Response::serverError(); return Response::noContent();
Building a response instance manually:
$response = (new Response()) ->status(201) ->header('X-Request-Id', 'abc123') ->cookie('session', 'token', time() + 3600, '/', '', true, true, 'Lax') ->body('Created') ->getResponse();
Creating a response from an array payload:
return Response::fromArray(['html' => '<p>Hello</p>'], 200, ['Content-Type' => 'text/html']);
URL Generator Examples
$generator = new \Marwa\Router\UrlGenerator($router->routes()); $plain = $generator->for('reports.show', ['year' => 2026]); $withQuery = $generator->for('reports.show', ['year' => 2026, 'format' => 'csv']); $signed = $generator->signed('reports.show', ['year' => 2026], 600, 'secret-key'); $isValid = $generator->verify($signed, 'secret-key');
Additional Patterns
RequestFactory Examples
Build a request from PHP globals:
use Marwa\Router\Http\RequestFactory; $request = RequestFactory::fromGlobals(); $response = $router->dispatch($request);
Build a synthetic request for tests or custom runtimes:
use Marwa\Router\Http\RequestFactory; $request = RequestFactory::fromArrays( server: [ 'REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/api/users?page=2', 'HTTP_HOST' => 'example.test', 'HTTP_ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/json', ], query: ['page' => 2], parsedBody: ['name' => 'Marwa', 'email' => 'marwa@example.com'], cookies: ['session' => 'abc123'], );
Trust reverse proxies explicitly before honoring forwarded headers:
use Marwa\Router\Http\RequestFactory; RequestFactory::trustProxies([ '127.0.0.1', '10.0.0.0/8', ]);
With trusted proxies configured, X-Forwarded-Host, X-Forwarded-Proto, and X-Forwarded-For are used to build the effective request URI and client IP. Without trusted proxies, those headers are ignored.
Trusted hosts can be restricted separately:
RequestFactory::trustHosts([ 'example.com', '*.example.com', ]);
Requests for other hosts will raise Marwa\Router\Exceptions\UntrustedHostException.
Clear trust configuration in tests or workers:
RequestFactory::clearTrustedProxies(); RequestFactory::clearTrustedHosts();
Redirect and Cookie Examples
Simple redirect:
return \Marwa\Router\Response::redirect('/login');
Manual response with headers and cookies:
$response = (new \Marwa\Router\Response()) ->status(200) ->header('X-Frame-Options', 'DENY') ->addHeader('Cache-Control', 'no-store') ->cookie('session', 'token', time() + 3600, '/', '', true, true, 'Lax') ->body('Authenticated') ->getResponse(); return $response;
Custom Not Found Handler
HTML fallback:
$router->setNotFoundHandler(static function (): \Psr\Http\Message\ResponseInterface { return \Marwa\Router\Response::html('<h1>404</h1><p>Page not found.</p>', 404); });
JSON fallback that sees the request:
use Psr\Http\Message\ServerRequestInterface; $router->setNotFoundHandler(static function (ServerRequestInterface $request): array { return [ 'message' => 'Route not found', 'path' => $request->getUri()->getPath(), ]; });
Controller Discovery Examples
Register a specific list of controllers:
$router->registerFromClasses([ \App\Controller\HomeController::class, \App\Controller\UserController::class, ]);
Scan more than one directory:
$router->registerFromDirectories([ __DIR__ . '/../src/Controller', __DIR__ . '/../modules/Billing/Controller', ], strict: true);
Route Registry Cache Example
Write the discovered route registry to disk:
$router->registerFromDirectories([__DIR__ . '/../src/Controller'], strict: true); $router->cacheRoutesTo(__DIR__ . '/../var/cache/routes.php');
Load the exported registry in another process:
$router = new \Marwa\Router\RouterFactory(); $router->loadRoutesFrom(__DIR__ . '/../var/cache/routes.php');
This only restores the route metadata returned by routes(). It does not rebuild runtime dispatch rules by itself, so keep normal route registration in your application bootstrap.
Load the compiled bootstrap cache instead when you want faster startup:
$router = new \Marwa\Router\RouterFactory(); $router->loadCompiledRoutesFrom(__DIR__ . '/../var/cache/routes.compiled.php');
Logger Example
use Monolog\Logger; use Monolog\Handler\StreamHandler; $logger = new Logger('router'); $logger->pushHandler(new StreamHandler('php://stderr')); $router->setLogger($logger);
The router logs missing routes and invalid not-found handler responses. ThrottleMiddleware logs rate-limit violations when a logger is attached to the router.
Conflict Detection Example
Conflict detection is enabled by default. You can disable it when you intentionally layer multiple route sources and want last-write behavior:
$router->enableConflictDetection(false);
Development
Install dependencies:
composer install
Useful commands:
composer testruns PHPUnitcomposer test:coverageprints a text coverage reportcomposer analyseruns PHPStancomposer lintruns PHP-CS-Fixer in dry-run modecomposer fixapplies coding-style fixescomposer validate:composervalidates package metadatacomposer ciruns the local validation gate
Project Layout
src/core library codetests/PHPUnit tests and fixturesexamples/runnable demo applicationbin/CLI helpers.github/workflows/CI configuration
Production Notes
- Keep
strict_types=1enabled in application code - Use
strict: truefor controller discovery in deployment builds - Provide a real shared PSR-16 cache backend when throttling matters
- Prefer PSR-11 container resolution for non-trivial controllers and middleware
- Return
ResponseInterface,string, orarrayfromsetNotFoundHandler() - Call
RequestFactory::trustProxies(...)only for proxy IPs you actually control - Call
RequestFactory::trustHosts(...)to reject unexpected host headers early - Prefer
subdomainFor('example.com')over naive host splitting in multi-tenant apps - Use
handle()in tests, worker runtimes, and custom HTTP emitters - Attach a PSR-3 logger with
setLogger()if you want visibility into missing routes and throttling events
Errors
- Missing routes raise
Marwa\Router\Exceptions\RouteNotFoundExceptionwhen no custom not-found handler is configured - Invalid not-found handler return values raise
Marwa\Router\Exceptions\InvalidNotFoundHandlerResponseException - Untrusted hosts raise
Marwa\Router\Exceptions\UntrustedHostException - Route definition conflicts raise
Marwa\Router\Exceptions\RouteConflictException - Invalid throttle or attribute definitions raise
Marwa\Router\Exceptions\InvalidRouteDefinitionException
Contributing
See AGENTS.md for repository-specific contributor guidance.