pauldeano / dromos
A small, fast PHP router for APIs: PSR-7, PSR-15 and PSR-17, with a lean mode and an opcache route cache
Fund package maintenance!
Requires
- php: >=8.2
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0.2
- psr/http-server-middleware: ^1.0.2
Requires (Dev)
- http-interop/http-factory-tests: ^2.2
- nyholm/psr7: ^1.8
- php-http/psr7-integration-tests: ^1.4
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0
Suggests
- ext-openswoole: Needed for SwooleEmitter (OpenSwoole 22 or later)
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.5.1
- v1.5.0
- v1.4.0
- v1.3.0
- v1.2.0
- v1.1.0
- v1.0.9
- v1.0.8
- v1.0.7
- v1.0.6
- v1.0.5
- v1.0.4
- dev-site/promo
- dev-docs/benchmark-method
- dev-docs/readme-retarget
- dev-perf/cache-load
- dev-feat/psr17-factories
- dev-feat/psr7-compliance
- dev-feat/psr
- dev-feat/lean-match
- dev-bench/matchers
- dev-perf/cached-path
- dev-bench/dataset
- dev-fix/package-contents
- dev-feat/swoole-emitter
- dev-fix/uploaded-file
- dev-feat/rate-limit-store
- dev-fix/envloader
- dev-swarm/13-uploaded-file-strategy
- dev-swarm/11-swoole-emitter
- dev-swarm/12-envloader-bootstrap-guard
- dev-swarm/6-rate-limit-store-interface
- dev-swarm/5-directory-casing-mismatch
- dev-swarm/1-envloader-malformed-lines
- dev-feature/api-service-enhancements
This package is auto-updated.
Last update: 2026-10-25 13:56:59 UTC
README
Dromos
A PHP router for fast APIs: dromos.codenexis.com. In the router comparison it beats Symfony Routing, FastRoute, Laravel's router, Flight, Slim and Mezzio in every in-process test at 2,000 routes. It loads 5 files to route a request, and it depends only on the PSR interface packages.
Router::Get('/users/{id}', [UserController::class, 'show']); $route = Router::match($_SERVER['REQUEST_METHOD'], $path); // [200, $handler, $params, $middleware]
The numbers
Microseconds each router spends on a request to a route with a parameter, on an Apple M3 Pro with PHP 8.4.4 and opcache. Lower is better.
| Worker, 20 routes | Worker, 2,000 routes | PHP-FPM cached, 20 routes | PHP-FPM cached, 2,000 routes | Files loaded | |
|---|---|---|---|---|---|
| Dromos lean | 0.81 | 0.83 | 1.73 | 1.74 | 5 |
| Dromos | 2.40 | 2.43 | 4.46 | 4.45 | 25 |
| Symfony Routing | 0.87 | 1.36 | 1.66 | 2.16 | 10 |
| FastRoute | 0.61 | 16.7 | 2.21 | 20.5 | 10 |
| Laravel | 39.9 | 41.1 | 63.8 | 66.3 | 75 |
| Slim | 11.1 | 29.1 | 47.1 | 1,653 | 68 |
| Mezzio | 11.5 | 141 | 72.6 | 2,925 | 103 |
| Flight | 25.8 | 1,373 | 74.4* | 4,889* | 10 |
* Flight has no route cache. The router comparison has every test, including real HTTP requests, with the spread between runs and every sample.
How it's measured
- Routes: 5, 50 or 500 resources with four controller routes each, so 20, 200 or 2,000 routes:
GET /res{n},GET /res{n}/{id},GET /res{n}/{id}/items/{item}andPOST /res{n}. The table requests a route of the last resource registered, which is the worst case for routers that try their routes in turn. Every handler returns a small JSON body. - Correctness: before anything is timed, each router must return the expected status and body for every request in the set, or the run stops.
- Setup: each router is set up as its documentation recommends, with its own route cache where it has one. FastRoute and Symfony Routing only match, so their adapters add the least a front controller needs: create the controller, pass the parameters and return the JSON. Laravel is
illuminate/routingon its own, without the rest of the framework. Both choices favour the other routers. - Worker: the router is booted once, then one request after another is timed in a loop.
- PHP-FPM cached: every iteration creates a new router, loads its routes from its cache and handles one request. This runs in one process with opcache on, so loading the router's files isn't counted. The HTTP tests in the router comparison count that.
- Timing: each test runs for 5 rounds of 60 ms, timed with
hrtime(), after one untimed call per round. Each script runs 5 times in a fresh process, and a result is the median across runs of each run's median. - Machine: a MacBook Pro with an Apple M3 Pro, macOS, PHP 8.4.4 with opcache, no JIT and no Xdebug. Package versions are in the report.
To run it yourself (about 90 minutes, and it needs ab):
cd benchmark/compare composer install php run.php php report.php results/<name>
Why Dromos
Most of the routers we tested slow down as you add routes. FastRoute tries its routes in batches of about ten, so at 2,000 routes it is 27 times slower than at 20. Dromos finds exact paths with a hash lookup, and groups routes with placeholders by their static prefix, so /users/42 is only tested against the routes under /users. It takes the same time with 20 routes as with 2,000.
You can use it two ways. Router::match() only finds the route and leaves the rest to you, which is all many APIs need. Router::handle() gives you PSR-7 requests and responses and runs middleware, including any PSR-15 middleware. Both use the same routes and the same cache, so you can start lean and add middleware later.
Under PHP-FPM, Router::loadRoutes() keeps the compiled routes in a PHP file that opcache holds in memory, so a request touches no file and routing takes about 1.7 µs in lean mode, or 4.5 µs with middleware. The cache rebuilds itself when you change your routes. Under OpenSwoole, routes are built once per worker.
It's also small enough to read in an afternoon. Lean mode is 5 files and 834 lines; full mode is 25 files and 3,424 lines, a third of which are the PSR-7 interfaces, mostly docblocks. The messages pass the community PSR-7 and PSR-17 test suites.
When to use something else
With only a handful of routes, FastRoute and Symfony win some tests, by a fraction of a microsecond. If you want an ORM, templating and a container, use Laravel or Symfony; Dromos is a router with middleware, validation and response helpers. And routing is rarely the slow part of a request: one database query costs more than all of this. Dromos matters most for APIs that serve a lot of small requests.
Installation
composer require pauldeano/dromos
Requires PHP 8.2+. The only dependencies are the four PSR interface packages: psr/http-message, psr/http-server-handler, psr/http-server-middleware and psr/http-factory.
Features
- Route parameters, groups with shared middleware, resource routes, and a 405 with an
Allowheader when only the method is wrong - Controllers from your own container, through
ControllerResolverInterface - Lean mode:
Router::match()returns[200, $handler, $params, $middleware],[404]or[405, $allowedMethods], like FastRoute - Two route caches that rebuild themselves: a routes file for controller routes, and a compiled table for routes with closures
- Global, group and per-route middleware, Dromos or PSR-15, with CORS, auth and rate limiting included
- PSR-7 requests and responses, JSON body parsing, JSON errors and response helpers
- Input validation with rules such as
required|email - Emitters for PHP-FPM and OpenSwoole, or implement
EmitterInterfacefor anything else
Routing
Basic Routes
use Dromos\Router; use Dromos\Http\Request; use Dromos\Http\Response; Router::Get("/users", function (Request $request, Response $response) { return $response->json(['users' => []]); }); Router::Post("/users", function (Request $request, Response $response) { $body = $request->getParsedBody(); return $response->created(['id' => 1, 'name' => $body['name']]); });
All HTTP methods are supported:
Router::Get("/path", $target); Router::Post("/path", $target); Router::Put("/path", $target); Router::Patch("/path", $target); Router::Delete("/path", $target); Router::Head("/path", $target); Router::Options("/path", $target);
Route Parameters
Router::Get("/users/{id}", function (Request $request, Response $response) { $id = $request->getAttribute('id'); return $response->json(['id' => $id]); });
A placeholder matches one path segment (anything except /). If a placeholder name appears twice, both segments must hold the same value.
Matching Order
Routes are matched in the order they are registered, and the first match wins. Register specific routes before general ones:
Router::Get("/users/me", [UserController::class, 'me']); // matches /users/me Router::Get("/users/{id}", [UserController::class, 'show']); // matches everything else
If /users/{id} were registered first, it would also match /users/me.
Under the hood, the router builds a lookup table the first time it dispatches, and again whenever routes change. Exact paths are found with a single hash lookup. Parameterised routes are grouped by the static segments before their first placeholder (/users for /users/{id}), and each group is combined into one regex, so a request only tests the few routes that could match its path. In a long-running server such as OpenSwoole, the table is built once and reused for every request.
Using Controllers
Router::Get("/users", [UserController::class, 'index']); Router::Get("/users/{id}", [UserController::class, 'show']); Router::Post("/users", [UserController::class, 'store']); Router::Put("/users/{id}", [UserController::class, 'update']); Router::Patch("/users/{id}",[UserController::class, 'patch']); Router::Delete("/users/{id}",[UserController::class, 'destroy']);
A controller is created the first time one of its routes is dispatched, then reused for every later request in the same process. There's one instance per class, shared by all of that class's routes. Under PHP-FPM that means at most one per request, and only for the controller that's actually used. Under OpenSwoole each worker keeps one instance per class, so don't keep per-request state in controller properties.
To build controllers some other way, such as from a dependency injection container, implement ControllerResolverInterface and register it:
use Dromos\Routing\ControllerResolverInterface; final class ContainerControllerResolver implements ControllerResolverInterface { public function __construct(private ContainerInterface $container) {} public function resolve(string $class): object { return $this->container->get($class); } } Router::setControllerResolver(new ContainerControllerResolver($container));
A handler must return a ResponseInterface. Anything else becomes a 500 error.
Resource Routes
Auto-register all HTTP methods for a controller:
Router::Resource("/users/{id}", UserController::class);
By default, the controller must have public methods named get, post, put, patch, delete, options, head. Routes are registered when the RouteResource object goes out of scope, so chained methods are applied before registration.
Customising Resource Methods
// API resource (GET, POST, PUT, PATCH, DELETE only — excludes OPTIONS and HEAD) Router::Resource("/users/{id}", UserController::class)->apiResource(); // Exclude specific methods Router::Resource("/users/{id}", UserController::class) ->exceptMethods(["HEAD", "OPTIONS", "DELETE"]); // Only specific methods Router::Resource("/users/{id}", UserController::class) ->onlyMethods(["GET", "POST"]);
Route Groups
Group routes under a shared prefix with optional per-group middleware:
use Dromos\Middleware\AuthMiddleware; Router::group('/api/v1', function ($group) { // Public routes $group->get('/status', function (Request $request, Response $response) { return $response->json(['status' => 'ok']); }); // Protected routes with auth middleware — define middleware BEFORE nested groups $group->group('/users', function ($users) { $users->middleware(new AuthMiddleware(function ($token) { return $token === 'valid-token' ? ['user_id' => 1] : false; })); $users->get('/list', [UserController::class, 'index']); $users->post('/create', [UserController::class, 'store']); $users->get('/{id}', [UserController::class, 'show']); $users->put('/{id}', [UserController::class, 'update']); $users->delete('/{id}', [UserController::class, 'destroy']); }); });
Nested groups inherit the parent's prefix and middleware stack at the point of creation. Add middleware to a group before defining nested groups to ensure inheritance.
Lean mode
Router::handle() builds a request and a response, runs middleware and calls the handler for you. If you don't need those, Router::match() only finds the route, as a matcher-only router such as FastRoute does, and you call the handler yourself. In a worker it's three to six times faster per request; see the router comparison.
// public/index.php Router::loadRoutes(__DIR__ . '/../routes.php', __DIR__ . '/../storage/cache/routes.php'); $path = (string) parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $route = Router::match($_SERVER['REQUEST_METHOD'], $path); switch ($route[0]) { case 200: [, [$class, $action], $params] = $route; header('Content-Type: application/json'); echo json_encode((new $class())->{$action}(...$params)); break; case 405: http_response_code(405); header('Allow: ' . implode(', ', $route[1])); break; default: http_response_code(404); }
The result follows FastRoute's shape:
| Result | When |
|---|---|
[200, $handler, $params, $middleware] |
A route matches. $handler is the closure or [Controller::class, 'method'] you registered, and $params holds the placeholder values by name. |
[404] |
No route matches the path. |
[405, $allowedMethods] |
Routes match the path, but only under other methods. |
- It uses the same routes and caches as
handle(), includingloadRoutes(), so you can switch between the two. - Middleware isn't run. The route's own middleware is returned in
$middleware, so check it if you rely on it, for example for authentication. Middleware added withaddMiddleware()belongs to aRouterinstance and isn't seen bymatch(). - Pass the path only, without the query string.
Middleware
Dromos runs its own middleware and PSR-15 middleware. Either can be applied globally or per route or group.
Global Middleware
$router = new Router(); $router->addMiddleware(new CorsMiddleware()); $router->addMiddleware(new RateLimitMiddleware(100, 60));
Per-Route / Per-Group Middleware
See Route Groups above for per-group middleware. Middleware can be an instance or a class name:
$group->middleware(new AuthMiddleware($authenticator)); // an instance, built now $group->middleware(RequireApiKey::class); // a class name, built on first use Router::setRouteMiddleware('GET', '/reports', [RequireApiKey::class]);
A class name is built by the controller resolver the first time a request reaches one of its routes, and reused after that, so a request never pays for middleware it doesn't use. Routes with class-name middleware can also be cached by Router::loadRoutes(). The default resolver calls the constructor with no arguments; for middleware that needs some, register a resolver backed by your container with Router::setControllerResolver().
PSR-15 Middleware
Middleware that implements Psr\Http\Server\MiddlewareInterface works anywhere Dromos middleware does: globally, per route or group, as an instance or a class name, and from the route cache.
$router->addMiddleware(new SomePackage\Psr15Middleware()); $group->middleware(SomePackage\OtherPsr15Middleware::class);
- Dromos middleware implements
Dromos\Http\Middleware\MiddlewareInterface, whose method ishandle(). PSR-15's isprocess(). The two can be mixed in one chain. - Messages from other PSR-7 libraries are converted. If a PSR-15 middleware passes on a request, or returns a response, built with another PSR-7 library (tested with Nyholm's), it's copied into a Dromos request or response before Dromos code sees it. Handlers can also return any PSR-7 response.
- Handlers can ask for PSR-7 types: a parameter typed
Psr\Http\Message\ServerRequestInterfacegets the request, and one typedResponseInterfacegets a new response, as the Dromos types do.
To use the router where a PSR-15 request handler is expected, for example by a PSR-15 server, wrap it. The wrapper accepts any PSR-7 server request:
$handler = new Dromos\Http\PsrRequestHandler(new Router());
PSR-17 Factories
Dromos\Http\HttpFactory implements every PSR-17 factory: requests, responses, server requests, streams, uploaded files and URIs. Give it to PSR-15 middleware that asks for a factory, so the responses it builds are Dromos responses:
$factory = new Dromos\Http\HttpFactory(); $router->addMiddleware(new SomePackage\Psr15Middleware(responseFactory: $factory, streamFactory: $factory));
Dromos's messages and factories pass the community compliance suites, php-http/psr7-integration-tests and http-interop/http-factory-tests, which run with the rest of the tests.
Built-in Middleware
CORS Middleware
use Dromos\Middleware\CorsMiddleware; $cors = new CorsMiddleware([ 'allowed_origins' => ['https://example.com', 'https://app.example.com'], 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], 'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'], 'max_age' => 86400, 'allow_credentials' => true, ]); $router->addMiddleware($cors);
All config keys are optional. Defaults to allowed_origins: ['*'].
Handles OPTIONS preflight requests automatically with a 204 response. Returns 403 for disallowed origins on preflight.
Note: allow_credentials: true cannot be used with wildcard allowed_origins: ['*']. Specify explicit origins when using credentials. An InvalidArgumentException is thrown if this combination is detected.
Auth Middleware
Supports Bearer tokens and API keys:
use Dromos\Middleware\AuthMiddleware; $auth = new AuthMiddleware(function (string $token) { // Your authentication logic here. // Return a truthy value (user array/object) on success, or false/null on failure. // IMPORTANT: For API key comparison, use hash_equals() to prevent timing attacks. $user = MyUserService::validateToken($token); return $user ?: false; });
- Extracts
Bearer <token>from theAuthorizationheader - Falls back to the
X-API-Keyheader - On success, stores the result as
$request->getAttribute('auth_user') - On failure, returns a 401 JSON response
Rate Limit Middleware
use Dromos\Middleware\RateLimitMiddleware; // 100 requests per 60-second window $rateLimiter = new RateLimitMiddleware(100, 60); $router->addMiddleware($rateLimiter);
Adds X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers to all responses. Returns 429 with Retry-After header when exceeded.
Counters are kept in a RateLimitStore. By default every RateLimitMiddleware shares one in-memory store, which only works in long-running processes (OpenSwoole, RoadRunner, ReactPHP). It tracks up to 10,000 client IPs and drops the oldest window beyond that. In tests, RateLimitMiddleware::resetStore() clears it.
PHP-FPM: each request has its own memory, so in-memory counters are never shared. Pass a store backed by shared storage instead. RateLimitStore has one method; this one uses APCu:
use Dromos\Middleware\RateLimitEntry; use Dromos\Middleware\RateLimitStore; final class ApcuRateLimitStore implements RateLimitStore { public function increment(string $key, int $windowSeconds): RateLimitEntry { $now = time(); $window = intdiv($now, $windowSeconds) * $windowSeconds; $name = "ratelimit:$key:$window"; // apcu_add only succeeds for the first request in a window; apcu_inc is atomic apcu_add($name, 0, $windowSeconds); return new RateLimitEntry((int) apcu_inc($name), $window); } } $router->addMiddleware(new RateLimitMiddleware(100, 60, new ApcuRateLimitStore()));
A Redis store works the same way with INCR and EXPIRE. APCu is shared between the workers of one PHP-FPM pool; use Redis to share limits between servers.
Custom Middleware
Implement MiddlewareInterface:
use Dromos\Http\Middleware\MiddlewareInterface; use Dromos\Http\Middleware\RequestHandlerInterface; use Dromos\Http\Message\ServerRequestInterface; use Dromos\Http\Message\ResponseInterface; class MyMiddleware implements MiddlewareInterface { public function handle( ServerRequestInterface $request, RequestHandlerInterface $handler ): ResponseInterface { // Before the route handler $request = $request->withAttribute('started_at', microtime(true)); // Call the next handler $response = $handler->handle($request); // After the route handler return $response->withHeader('X-Response-Time', '42ms'); } }
Request & Response
JSON Request Body Parsing
JSON request bodies (Content-Type: application/json) are automatically parsed and available via getParsedBody(). Request bodies are limited to 1MB by default with a max JSON nesting depth of 64.
Router::Post("/users", function (Request $request, Response $response) { $data = $request->getParsedBody(); // $data is the decoded JSON array return $response->created(['id' => 1, 'name' => $data['name']]); });
Response Helpers
All response helpers return a new immutable Response instance, replacing any body and Content-Type it had:
// JSON response (throws JsonException if the data can't be encoded) $response->json(['key' => 'value'], 200); $response->json(['url' => '/a/b'], 200, JSON_UNESCAPED_SLASHES); // optional json_encode() flags // Plain text $response->text('Hello World', 200); // HTML (writes raw unescaped content — sanitise user input before passing) $response->html('<h1>Hello</h1>', 200); // 201 Created with JSON body $response->created(['id' => 1]); // 204 No Content $response->noContent(); // Redirect (URL is sanitised against header injection) $response->redirect('/new-location', 302);
Error Responses
Routing errors return structured JSON. For RouterException errors (404, 405), the exception message is included. For unexpected errors, a generic "Internal Server Error" message is returned to prevent leaking internal details.
{
"error": "Not Found",
"message": "Route not found: /unknown",
"status": 404
}
A 405 also lists the allowed methods, under details.available_methods and in the Allow header. 404s and 405s are the client's errors, so they aren't written to the error log; 500s are, with the real exception message.
Input Validation
use Dromos\Validation\Validator; Router::Post("/users", function (Request $request, Response $response) { $validator = new Validator($request->getParsedBody(), [ 'name' => 'required|string|min:2|max:100', 'email' => 'required|email', 'age' => 'integer|min:0|max:150', 'role' => 'in:admin,user,editor', ]); if ($validator->fails()) { return $response->json(['errors' => $validator->errors()], 422); } $clean = $validator->validated(); // Only validated fields, unknown keys stripped return $response->created($clean); });
Note: validated() throws ValidationException if validation has not passed. Always check fails() first, or wrap in a try/catch. validated() returns the original values filtered by key — it does not sanitise or type-cast data.
Unknown rule names throw InvalidArgumentException to catch typos early.
Available Rules
| Rule | Description |
|---|---|
required |
Field must be present and non-empty |
string |
Must be a string |
integer |
Must be an integer |
numeric |
Must be numeric |
email |
Must be a valid email address |
url |
Must be a valid URL |
boolean |
Must be a boolean value |
array |
Must be an array |
min:n |
Minimum length (string), value (numeric), or count (array) |
max:n |
Maximum length (string), value (numeric), or count (array) |
in:a,b,c |
Must be one of the listed values |
regex:/pattern/ |
Must match the regex pattern (pipe characters inside regex are handled correctly) |
Rules are pipe-delimited: 'required|string|min:2|max:100'
Emitter
Send a response to the client:
use Dromos\Router; use Dromos\Http\Request; use Dromos\Http\Emitter\Emitter; $router = new Router(); $response = $router->handle(new Request()); $emitter = new Emitter(); $emitter->emit($response);
Running under OpenSwoole
SwooleEmitter sends a response through OpenSwoole's HTTP server. It needs the openswoole extension (22 or later). Register your routes once, before the server starts, and every worker reuses the compiled route table:
use Dromos\Http\Emitter\SwooleEmitter; use Dromos\Http\Request; use Dromos\Router; use OpenSwoole\Http\Request as SwooleRequest; use OpenSwoole\Http\Response as SwooleResponse; use OpenSwoole\Http\Server; require __DIR__ . '/vendor/autoload.php'; require __DIR__ . '/routes.php'; $router = new Router(); $server = new Server('0.0.0.0', 9501); $server->on('request', function (SwooleRequest $in, SwooleResponse $out) use ($router) { $query = $in->server['query_string'] ?? ''; $request = Request::create( method: $in->server['request_method'], uri: $in->server['request_uri'] . ($query === '' ? '' : '?' . $query), serverParams: array_change_key_case($in->server, CASE_UPPER), headers: $in->header ?? [], queryParams: $in->get ?? [], parsedBody: $in->post, body: (string) $in->rawContent(), ); (new SwooleEmitter($out))->emit($router->handle($request)); }); $server->start();
- JSON bodies: OpenSwoole only parses form posts. Decode
rawContent()intoparsedBodyyourself, asbenchmark/src/SwooleRequestFactory.phpdoes. - Shared controllers: each worker keeps one controller instance per class for every request it handles, so don't keep per-request state in controller properties.
- Headers: every value of a header is sent, including several
Set-Cookieheaders. - Bodies: a body that fits in 8 KB is sent in one piece with a
Content-Length. A larger file or socket stream is sent in 8 KB chunks with chunked transfer encoding, so it never sits in memory whole.
Other runtimes
Implement EmitterInterface to send responses anywhere else:
use Dromos\Http\Emitter\EmitterInterface; use Dromos\Http\Message\ResponseInterface; class MyEmitter implements EmitterInterface { public function emit(ResponseInterface $response): void { // Send the status, headers and body } }
Environment Variables
use Dromos\Env\EnvLoader; EnvLoader::load(__DIR__ . '/.env'); $dbHost = EnvLoader::get('DB_HOST', 'localhost');
Loads .env files into $_ENV and putenv(). Skips comments (#) and malformed lines. Falsy values like "0" are correctly preserved.
Each file is loaded once per process: calling load() again with the same file does nothing, while a different file (say .env.local) still loads. Under OpenSwoole or RoadRunner, call it once at bootstrap, before the server starts. $_ENV and putenv() are shared by every request a worker handles, so read values into your configuration there rather than per request.
Route Caching
Dromos has two route caches. Both store the compiled route table in a PHP file that opcache keeps in shared memory.
Cached routes file (fastest)
Put your routes in their own file and load it with a cache file:
// public/index.php Router::loadRoutes(__DIR__ . '/../routes.php', __DIR__ . '/../storage/cache/routes.php'); (new Emitter())->emit((new Router())->handle(new Request()));
// routes.php use Dromos\Router; Router::Get('/users/{id}', [UserController::class, 'show']); Router::group('/admin', function ($group) { $group->middleware(RequireAdmin::class); // by class name, so it can be cached $group->get('/stats', [StatsController::class, 'index']); });
The first request runs routes.php and stores the routes. Every later request dispatches straight from the cache: routes.php doesn't run, and nothing is registered or compiled. Under PHP-FPM, a request with 2,000 routes takes about the same time as one with 20.
- It rebuilds itself when
routes.phpchanges, as soon as PHP would notice changed code. While opcache holds both files as current,routes.phpisn't even checked, so a request touches no file. A change counts when opcache revalidatesroutes.php(everyopcache.revalidate_freqseconds, withopcache.validate_timestampson), or when opcache is reset or the file invalidated, as withopcache.validate_timestampsoff. Without opcache,routes.phpis checked on every request (its size, modification time and inode). - If
routes.phpincludes other route files, delete the cache file when you deploy changes to them. - Only controller routes can be stored. Closures and middleware instances can't be, so if
routes.phpdefines any, it simply runs on every request. Give middleware by class name to keep them cacheable; they're built by the controller resolver when a request first reaches one of their routes. - Routes registered before
loadRoutes()mean the file runs on every request, so the order of all routes is kept. Routes added after it work as normal. - Use a different file from
enableCache()below.
Compiled table cache
If your routes use closures, keep registering them on every request and let the cache skip compiling them:
ROUTER_CACHE_FILE=/var/www/app/storage/cache/routes.php
Router::initialize(); // Enables the cache if ROUTER_CACHE_FILE is set // Register your routes as normal, on every request Router::Get("/users/{id}", [UserController::class, 'show']);
Or manage it manually:
Router::enableCache('/var/www/app/storage/cache/routes.php'); Router::clearCache(); // delete the file; it's rebuilt on the next request Router::disableCache();
How it works:
- It stays current on its own. A signature of the registered routes is stored with the table. If a route is added, removed or changed, the file is rebuilt on the next request, so you don't need to clear it on deploy.
- Every route type works. Closures, controllers, groups and middleware all behave exactly as they do without the cache.
- A broken cache never fails a request. This is true of both caches: if the file can't be written, the error is logged and the routes are compiled as normal. A corrupt or outdated file is rebuilt.
Preloading
Under PHP-FPM, add Dromos to opcache's preload so its classes are compiled once when PHP-FPM starts, instead of being loaded on each request:
opcache.preload=/var/www/app/vendor/pauldeano/dromos/preload.php opcache.preload_user=www-data
Restart PHP-FPM after upgrading Dromos. If your app already has a preload script, require this one from it.
Security: cache files are run with require. Keep them in a directory only your application can write to, never a shared one such as /tmp on a multi-user server.
Upgrading from 1.1: the old JSON cache file is replaced automatically. In 1.1, routes in the cache file could be dispatched without being registered. That skipped their middleware and kept deleted routes alive. This still works in 1.2 but triggers an E_USER_DEPRECATED notice, and it stops working in 2.0. Register your routes on every request.
Full Example
<?php require_once __DIR__ . '/vendor/autoload.php'; use Dromos\Router; use Dromos\Http\Request; use Dromos\Http\Response; use Dromos\Http\Emitter\Emitter; use Dromos\Env\EnvLoader; use Dromos\Middleware\CorsMiddleware; use Dromos\Middleware\RateLimitMiddleware; use Dromos\Middleware\AuthMiddleware; use Dromos\Validation\Validator; // Load environment EnvLoader::load(__DIR__ . '/.env'); // Optional: enable route caching via ROUTER_CACHE_FILE env var Router::initialize(); // Define routes Router::group('/api', function ($api) { // Public $api->get('/health', function (Request $request, Response $response) { return $response->json(['status' => 'ok']); }); // Protected $api->group('/v1', function ($v1) { $v1->middleware(new AuthMiddleware(function ($token) { return MyAuth::validate($token); })); $v1->get('/users', [UserController::class, 'index']); $v1->post('/users', [UserController::class, 'store']); }); }); // Boot $router = new Router(); $router->addMiddleware(new CorsMiddleware()); $router->addMiddleware(new RateLimitMiddleware(100, 60)); $response = $router->handle(new Request()); (new Emitter())->emit($response);
Testing
Run the test suite and static analysis with:
composer install vendor/bin/phpunit vendor/bin/phpstan analyse
For unit testing, use Router::reset() between tests to clear all registered routes and middleware:
Router::reset(); RateLimitMiddleware::resetStore();
Creating Test Requests
Use Request::create() to build requests without depending on PHP superglobals:
use Dromos\Http\Request; $request = Request::create( method: 'POST', uri: '/api/users', headers: ['Content-Type' => 'application/json'], parsedBody: ['name' => 'Jane'] );
License
MIT