indragunawan/api-rate-limit-bundle
A PHP package for adding configurable rate limiting to APIs. Define request limits, track usage per endpoint, and protect your application from abuse with simple throttling and clear rate-limit responses. Suitable for REST APIs that need lightweight, customizable request control.
Prerequisites. PHP 7.4+ (8.x preferred), Symfony 5.2+ with a PSR-6 cache pool or Redis available, and API Platform 2.6/2.7. The bundle's last release was April 2021, so confirm it still resolves against your API Platform version before you build on it — see Gotchas.
Install and register.
composer require indragunawan/api-rate-limit-bundle
Flex (or a manual edit to config/bundles.php) registers the bundle:
IndraGunawan\ApiRateLimitBundle\ApiRateLimitBundle::class => ['all' => true],
Minimum viable config — config/packages/api_rate_limit.yaml:
api_rate_limit:
enabled: true
default_limit: 60
default_period: 60 # seconds
cache: cache.app # point this at a Redis-backed pool in production
First use case. Rate-limit the single endpoint that costs you CPU or money — usually a collection/search operation. Annotate the resource (attribute shown; older API Platform releases use the analogous annotation):
use IndraGunawan\ApiRateLimitBundle\Attribute\RateLimit;
#[ApiResource]
#[RateLimit(limit: 30, period: 60)]
class SearchResult
{
// ...
}
Then exercise it with curl -i in a loop and watch for the X-RateLimit-* headers and the 429 on the request after the limit. If you don't see them, the limiter isn't wired up — stop and debug before adding more limits.
Where to look first, in order:
DependencyInjection/Configuration.php — the only authoritative list of config keys and defaults.limit, period, maybe key).src/ for 429 or TooManyRequests. This is the class you decorate or override when defaults don't fit.Resource default, operation override. Set a sane default_limit at the bundle level, a tighter limit on the resource, and tighter still on write operations:
#[ApiResource(operations: [
new GetCollection(),
new Post(),
])]
#[RateLimit(limit: 300, period: 60)]
class Order
{
// Post operation can get its own attribute in most versions
}
Tiered windows for burst vs. sustained. Pair a short window with a long one (e.g. 10/min and 500/hour) rather than one large window. Clients that hammer get cut off quickly; legitimate batch workers still have headroom.
Pick the key deliberately. The default is usually client IP, which is wrong for authenticated APIs (all users behind the same office NAT share a bucket) and too generous for anonymous endpoints. Configure or extend key resolution:
user_id + route) so one hot endpoint doesn't starve the rest.Storage choice.
cache.app is fine.Give clients a contract. Ensure the response carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (or equivalent) on successful responses too, plus Retry-After on the 429. Document those headers and the 429 response in your OpenAPI decorators so SDK consumers can back off correctly.
Keep non-API routes consistent. The bundle only covers API Platform resources. If you also expose plain controllers, mirror the policy with Symfony's RateLimiter component or a kernel.request listener so limits are uniform across the app.
Testing pattern. A functional test is the cheapest regression guard:
public function testRateLimitHeadersAnd429(): void
{
for ($i = 0; $i < 30; $i++) {
$this->client->request('GET', '/api/search_results');
$this->assertResponseIsSuccessful();
}
$this->client->request('GET', '/api/search_results');
$this->assertResponseStatusCodeSame(429);
$this->assertResponseHeaderSame('Retry-After', '60');
}
Reset the limiter storage in setUp/tearDown (clear the pool or flush the test Redis DB), and use unique client keys per test.
Observability. Log every 429 with the resolved key and route, and emit a counter metric. A silent limit is a mystery outage waiting to happen.
#[RateLimit] on operations, or may fail to boot at all. Pin API Platform 2.7 if you must use it, or fork and adapt the metadata-reading code. Budget an afternoon for this before adopting it widely.Request::setTrustedProxies() (or framework.trusted_proxies) correctly. Verify by inspecting the resolved key in logs.$request->attributes->get('_route').api_rate_limit.yaml, run bin/console cache:clear — Symfony serves the compiled config otherwise and you'll chase ghosts.kernel.response listener for custom headers. These usually cover 90% of real-world needs without touching upstream code.How can I help you explore Laravel packages today?