A minimal rate limiting middleware for PSR 15, built on symfony/rate-limiter.
- php: ^8.3
- chubbyphp/chubbyphp-http-exception: ^1.3.3
- psr/clock: ^1.0
- psr/http-message: ^1.1|^2.0
- psr/http-server-handler: ^1.0.2
- psr/http-server-middleware: ^1.0.2
- symfony/rate-limiter: ^7.3|^8.0
- chubbyphp/chubbyphp-laminas-config-factory: ^1.5.2
- chubbyphp/chubbyphp-trusted-proxy: ^1.1
- symfony/cache: ^7.3|^8.0
- symfony/lock: ^7.3|^8.0
Through Composer as chubbyphp/chubbyphp-rate-limit.
composer require chubbyphp/chubbyphp-rate-limit "^1.0"The middleware is a thin layer on top of symfony/rate-limiter: it resolves the key of a request (usually the
client ip), consumes one token from the limiter of a RateLimiterFactoryInterface and translates the result into the
RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds) headers, or throws a 429 Too Many Requests
HttpException of chubbyphp-http-exception once the limiter rejects.
The header names follow the widely supported earlier drafts of draft-ietf-httpapi-ratelimit-headers (separate
RateLimit-* headers); the current drafts fold them into a single structured RateLimit header, which is not
supported by most clients yet.
<?php
declare(strict_types=1);
namespace App;
use Chubbyphp\RateLimit\AttributeKeyResolver;
use Chubbyphp\RateLimit\HeaderKeyResolver;
use Chubbyphp\RateLimit\KeyResolver;
use Chubbyphp\RateLimit\RateLimitMiddleware;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
$app = ...;
$app->add(new RateLimitMiddleware(
// the first resolved key wins, the last resolver should resolve a key for every request
new KeyResolver(new AttributeKeyResolver('clientIp'), new HeaderKeyResolver('X-Api-Key')),
// 100 requests per minute, see symfony/rate-limiter for the policies (fixed_window, sliding_window, token_bucket)
// and the storages (InMemoryStorage counts within a single process only, see "Shared limits between processes")
new RateLimiterFactory(
['id' => 'api', 'policy' => 'fixed_window', 'limit' => 100, 'interval' => '1 minute'],
new InMemoryStorage(),
),
));The token gets consumed before the handler runs: a request counts whether the handler succeeds, fails or throws (the
request itself is the cost, not its outcome). To count only certain outcomes, wrap the RateLimiterFactoryInterface
or run the middleware after the check which decides.
RateLimit-Reset (and the reset of the exception) is the number of seconds until the next request gets accepted,
as symfony/rate-limiter reports it: 0 as long as requests remain, the end of the window (or the time the next
token gets refilled) once they are used up. The end of the window is not reported while requests remain, as the limiter
does not expose it.
The middleware does not parse X-Forwarded-For (or any other forwarded header) itself: every proxy appends the
address it saw, so the first entry is whatever the client sent, and any client could pick its own key (and thereby its
own limit). Use chubbyphp/chubbyphp-trusted-proxy in front of this middleware instead: it decides which entries
of the forwarded headers to trust and sets the clientIp attribute, which new AttributeKeyResolver('clientIp')
reads.
use Chubbyphp\TrustedProxy\ForwardedResolver;
use Chubbyphp\TrustedProxy\TrustedProxyMiddleware;
// the ips / cidrs of the proxies, see chubbyphp/chubbyphp-trusted-proxy
$app->add(new TrustedProxyMiddleware(new ForwardedResolver(['10.0.0.0/8', '::1'])));
// the trusted proxy middleware must run before the rate limit middleware
$app->add($rateLimitMiddleware);The clientIp is only as trustworthy as the proxies: each of them must set (append to, or strip) the forwarded
headers, never pass the ones of the client through (e.g. nginx needs
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;, it passes the header through by default). A proxy
which passes them through lets the client fake a trusted hop, and thereby pick its own clientIp (and key).
HeaderKeyResolver is meant for headers the client legitimately owns, like an X-Api-Key (the trimmed header line
is the key as is, multiple values comma joined), not for forwarded headers. Such a header must be authenticated
before this middleware (an authentication middleware in front rejecting unknown keys, and multiple values, as the
line known, other is another key), as otherwise any unknown value is a fresh key with its own limit. Keep in mind
that a client who simply omits such a header gets no key: either chain a resolver with a key every request has (e.g.
new AttributeKeyResolver('clientIp'), or as a last resort new StaticKeyResolver('global'), which puts all
remaining requests into one shared limit) behind it, or reject requests without the header before this middleware.
The shipped resolvers namespace their keys by source: header:<lower cased name>:<value>,
attribute:<name>:<value> and static:<value>. The same value out of two resolvers of a chain is thereby two keys
(two counters): an X-Api-Key: 203.0.113.1 does not consume the limit of the client with the clientIp
203.0.113.1, and an X-Api-Key: global not the one of the StaticKeyResolver('global'). Own resolvers should
namespace their keys the same way, as nothing else tells the sources apart.
The middleware hashes the (namespaced) key (sha256, hex) before it reaches the RateLimiterFactoryInterface: the id
within the storage is <id>-<64 hex chars> whatever the key is (the limiter prefixes the key with the id of its
configuration). A client controlled value (e.g. a header of some 100 KB) can thereby neither grow the storage per
key nor hit a key restriction of a storage backend, and a secret used as key (an api key) is not stored as is. To
inspect or reset the counter of a key within the storage, hash it the same way (hash('sha256', 'attribute:clientIp:203.0.113.1')).
The hash bounds the size of a key, not their number: every distinct key allocates its own counter in the storage
(an entry per key until the window ends, InMemoryStorage only frees expired ones once the same key gets requested again).
A resolver which lets a client pick arbitrary keys (like a freely spoofable header) lets it grow the storage without
bound, which is why the key space should not be under the control of the client.
The middleware fails closed: a request without a resolved key (no matching header / attribute) is treated as a
misconfiguration and fails with a MissingRateLimitKeyException (a RuntimeException) instead of passing unlimited, as do exceptions of the limiter
(e.g. an unreachable redis of the storage; wrap the RateLimiterFactoryInterface to fall back to another limiter). To
exempt requests from the rate limit, do not run the middleware for them (e.g. register it per route or route group).
InMemoryStorage counts within a single process, which is only useful for long running runtimes (swoole, workerman,
...) and tests. Pass a CacheStorage with any PSR 6 cache pool (e.g. a redis adapter of symfony/cache) to share
the limits between the processes / servers.
The limiter reads, updates and writes a counter in three steps whatever the storage is, so concurrent requests of one
key can each read the same count: without a lock a limit of 10 may accept a few more than 10 requests arriving at
once (as many as run concurrently, 16 out of 40 in a test). Pass a lock factory of symfony/lock (the third
argument of the RateLimiterFactory, a store next to the storage, e.g. a RedisStore on the same redis) for an
exact limit, the counter of a key is then updated by one request at a time:
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\CacheStorage;
$redis = RedisAdapter::createConnection('redis://localhost');
$rateLimiterFactory = new RateLimiterFactory(
['id' => 'api', 'policy' => 'sliding_window', 'limit' => 100, 'interval' => '1 minute'],
new CacheStorage(new RedisAdapter($redis)),
new LockFactory(new RedisStore($redis)),
);Once the limit is exceeded the middleware does not return a response, it throws a 429 Too Many Requests
HttpException of chubbyphp-http-exception, so that the exception middleware of the application (e.g. the one of
chubbyphp-framework or chubbyphp-api) turns it into the 429 response and rate limit errors look like
every other error (problem json, logging, ...). The exception carries:
limit,remaining,reset(seconds) andretryAfter(seconds, at least1) as additional problem details withinjsonSerialize()detailandinstancename the method and path of the request only (no scheme / host, no query string, which may carry tokens), as they end up in the response body, logs and error trackers. The path is not filtered: a token within it (e.g./reset-password/<token>) ends up there as well, treat such routes like a query string (aPOSTbody instead, or a route which does not run the middleware)
The shipped exception middlewares do not set headers out of an exception, so set the RateLimit-* and Retry-After
headers of the 429 response out of the problem details (in front of the exception middleware, or within your own
one):
use Chubbyphp\HttpException\HttpException;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class RateLimitHeadersMiddleware implements MiddlewareInterface
{
private const array HEADERS = [
'RateLimit-Limit' => 'limit',
'RateLimit-Remaining' => 'remaining',
'RateLimit-Reset' => 'reset',
'Retry-After' => 'retryAfter',
];
public function __construct(private readonly ResponseFactoryInterface $responseFactory) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
return $handler->handle($request);
} catch (HttpException $e) {
if (429 !== $e->getStatus()) {
throw $e;
}
$data = $e->jsonSerialize();
$response = $this->responseFactory->createResponse($e->getStatus())
->withHeader('Content-Type', 'application/problem+json');
foreach (self::HEADERS as $name => $key) {
$response = $response->withHeader($name, (string) $data[$key]);
}
$response->getBody()->write(json_encode($data, \JSON_THROW_ON_ERROR));
return $response;
}
}
}The package ships service factories (built on chubbyphp-laminas-config-factory) for a PSR 11 container, configured
through config.chubbyphp.rateLimit:
<?php
declare(strict_types=1);
namespace App;
use Chubbyphp\Laminas\Config\Config;
use Chubbyphp\Laminas\Config\ContainerFactory;
use Chubbyphp\RateLimit\KeyResolverInterface;
use Chubbyphp\RateLimit\RateLimitMiddleware;
use Chubbyphp\RateLimit\ServiceFactory\KeyResolverFactory;
use Chubbyphp\RateLimit\ServiceFactory\RateLimiterFactoryFactory;
use Chubbyphp\RateLimit\ServiceFactory\RateLimitMiddlewareFactory;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\RateLimiter\Storage\CacheStorage;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
$container = (new ContainerFactory())(new Config([
'chubbyphp' => [
'rateLimit' => [
// the key resolvers in order, the first resolved key wins
'keys' => [
// the clientIp attribute set by chubbyphp/chubbyphp-trusted-proxy (registered before this middleware)
['attribute' => 'clientIp'],
// a header name, e.g. an api key authenticated by a middleware in front (not X-Forwarded-For, see
// "Client ip behind a proxy")
['header' => 'X-Api-Key'],
// a fixed key for all remaining requests (one shared limit instead of no limit), optional
['static' => 'global'],
],
// every other key is an option of the symfony/rate-limiter RateLimiterFactory
'policy' => 'fixed_window',
'limit' => 100,
'interval' => '1 minute',
// 'id' => 'chubbyphp.rateLimit', ('chubbyphp.rateLimit.<name>' for named factories, see below)
],
],
'dependencies' => [
'factories' => [
KeyResolverInterface::class => KeyResolverFactory::class,
RateLimiterFactoryInterface::class => RateLimiterFactoryFactory::class,
RateLimitMiddleware::class => RateLimitMiddlewareFactory::class,
// the storage shared by the limiters, an InMemoryStorage if not registered
StorageInterface::class => static fn () => new CacheStorage(...),
],
],
]));
$rateLimitMiddleware = $container->get(RateLimitMiddleware::class);The RateLimitMiddlewareFactory uses the services KeyResolverInterface::class and
RateLimiterFactoryInterface::class of the container if registered, and creates them through the shipped
KeyResolverFactory and RateLimiterFactoryFactory otherwise. Register any of them under its name to replace it or
to share it with other services. The RateLimiterFactoryFactory uses the services StorageInterface::class (an
InMemoryStorage if not registered) and LockFactory::class (none if not registered) the same way, the
RateLimitMiddlewareFactory the service ClockInterface::class (PSR 20, the system time if not registered) as the
base for the reset seconds. The limiters of symfony/rate-limiter compute the reset out of the system time
(microtime()) whatever the clock is, so a clock which deviates from it (a fixed one in a test) skews the reported
seconds by the deviation.
To serve different parts of an application with different limits, the same factories can be registered multiple
times with a name: the config is then read from config.chubbyphp.rateLimit.<name> and the name gets appended to each
service id.
$container = (new ContainerFactory())(new Config([
'chubbyphp' => [
'rateLimit' => [
'api' => [
'keys' => [['attribute' => 'clientIp']],
'policy' => 'fixed_window',
'limit' => 1000,
'interval' => '1 minute',
],
'login' => [
'keys' => [['attribute' => 'clientIp']],
'policy' => 'sliding_window',
'limit' => 5,
'interval' => '15 minutes',
],
],
],
'dependencies' => [
'factories' => [
RateLimitMiddleware::class.'api' => [RateLimitMiddlewareFactory::class, 'api'],
RateLimitMiddleware::class.'login' => [RateLimitMiddlewareFactory::class, 'login'],
// shared by both, unless StorageInterface::class.'api' / StorageInterface::class.'login' is registered
StorageInterface::class => static fn () => new CacheStorage(...),
],
],
]));
$apiRateLimitMiddleware = $container->get(RateLimitMiddleware::class.'api');
$loginRateLimitMiddleware = $container->get(RateLimitMiddleware::class.'login');Without an explicit id a named limiter uses chubbyphp.rateLimit.<name>, so that named limiters sharing one storage
do not share their counters. The storage, lock factory and clock services get resolved with the name appended first
(StorageInterface::class.'login'), and without it (StorageInterface::class, shared by all names) otherwise.
2026 Dominik Zogg