noxlogic/ratelimit-bundle
RateLimitBundle adds flexible, configurable rate limiting to Symfony applications. Protect APIs and routes from abuse, define custom limits and strategies, and integrate quickly with minimal boilerplate. Ideal for throttling requests and controlling traffic.
First, the reality check every Laravel dev needs: noxlogic/ratelimit-bundle is a Symfony bundle, not a Laravel package. It has no service provider, no facade, and no config/ratelimit.php. It hooks kernel.controller and reads @RateLimit annotations/attributes. Composer will install it into a Laravel app, but nothing will wire up — you'd be hand-registering a Symfony DI extension inside Laravel's container. Don't. If you're in Laravel, the equivalent is Illuminate\Cache\RateLimiter + the throttle middleware, and you already have it.
So this assessment is written for the realistic case: you're on a polyglot team, or maintaining/porting a Symfony service alongside your Laravel ones, and you need to evaluate this bundle on its merits or mirror its behaviour in Laravel.
Minimal setup in an actual Symfony app:
composer require noxlogic/ratelimit-bundle
// config/bundles.php
Noxlogic\RateLimitBundle\NoxlogicRateLimitBundle::class => ['all' => true],
# config/packages/noxlogic_rate_limit.yaml
noxlogic_rate_limit:
enabled: true
storage_engine: redis # redis | memcache | in-memory | doctrine
rate_response_code: 429
display_headers: true
limiters:
anonymous: { limit: 60, period: 60 }
authenticated: { limit: 3000, period: 3600 }
paths:
'^/api/login':
limits: { anonymous: 5 }
methods: [POST]
'^/api':
limits: { anonymous: 60, authenticated: 3000 }
Where to look first, in this order:
| File | Why |
|---|---|
DependencyInjection/Configuration.php |
The real, authoritative config tree — the README lags behind |
Annotation/RateLimit.php (also attribute form) |
The per-action API: limit, period, key, methods |
EventListener/*Listener.php |
The kernel.controller hook — this is where enforcement actually happens |
Service/RateLimiter.php + RateLimiterInterface |
hit() / check() / reset() — your integration seam |
Storage/ |
Adapters per backend; the key format lives here |
First use case: throw it in front of POST /login and POST /password-reset. Those are the two endpoints where a rate limit is a security control rather than a politeness feature, and they're the fastest way to confirm the storage engine is actually shared across PHP-FPM workers (if it's in-memory, it isn't — see Gotchas).
Laravel mapping, for when you translate requirements between the two codebases:
| This bundle | Laravel |
|---|---|
paths: regex block |
Route::middleware('throttle:60,1') |
Named limiters: tiers |
RateLimiter::for('api', fn ($r) => ...) |
RateLimit(limit=, period=) attribute |
Per-route throttle: or a custom limiter |
RateLimiterInterface::hit() |
RateLimiter::hit($key, $decay) |
X-RateLimit-* headers |
throttle middleware headers (Laravel 10+) |
Path regex config is the primary mechanism, annotations are the exception handler. Use paths: for blanket rules on URL prefixes (API surface, auth endpoints), and reserve the @RateLimit attribute for one-off expensive actions — report generation, search, SMS send. Mixing both on the same route is allowed; the most restrictive limit wins, which is usually what you want but makes debugging harder.
Tiered limiters keyed by identity. Define anonymous and authenticated limiters and apply them by path; the bundle picks the matching limiter. The default key derives from the client IP, which is fine for anonymous traffic and wrong for authenticated traffic — a café NAT shouldn't share one bucket across fifty users. For authenticated endpoints, set the annotation/limiter key to a stable user or tenant identifier:
#[RateLimit(limit: 100, period: 3600, key: 'user')]
public function exportAction(): Response { /* ... */ }
(Verify the key-resolution signature against Annotation/RateLimit.php in your installed version — it changed between the annotation and attribute eras.)
Pick Redis, and only Redis, above one server. in-memory is per-process and therefore per-request under PHP-FPM; doctrine needs a shared cache pool. Memcached works but eviction under memory pressure silently resets limits — an attacker can't exploit it, but your metrics will lie. Redis gives you a shared counter plus inspectable keys, which matters enormously when someone asks "why was I blocked at 3am".
Own the 429 response. The bundle sets the status code and X-RateLimit-* headers, but the body is generic. Register a small kernel exception listener for RateLimitExceededException and return your API's standard error envelope plus a Retry-After. This is ~15 lines and is the difference between "professional API" and "Symfony default error page leaking a stack trace".
Cost-based limits need a different shape. The bundle is a counter, not a budget — one request costs one. If an endpoint's cost varies (bulk endpoint with ?ids= up to 500), rate-limit on a computed weight in your controller via RateLimiterInterface::hit($key) in a loop, or move the logic into a service and leave the bundle to cover the cheap routes.
Testing. Set storage_engine: in-memory in config/packages/test/ and reset between tests via the limiter's reset(). Never let functional tests touch the shared Redis you use in dev — you will flake your own test suite the first time a test suite runs the 61st request.
Sharing counters with a Laravel app. Tempting, and mostly a trap: the bundle's Redis key format (prefix + key) won't match Laravel's RateLimiter. Two apps hitting the same endpoint will each enforce their own budget. If you genuinely need a shared budget, put the limiter in one service (nginx/Envoy/API gateway) rather than trying to align key formats across frameworks.
framework.trusted_proxies / trusted_headers correctly. Validate before launch by echoing the resolved client IP.^/api matches; /api will also match /foo/api/bar and surprise you. Also check ordering — a broad ^/ catch-all listed first can shadow everything below it.@RateLimit(...) docblocks and require doctrine/annotations; newer ones use PHP 8 attributes. A silent no-op rate limit is almost always an annotation the reader never parsed. Verify by asserting a 429 in a functional test, not by reading config.redis locally.display_headers: true but the route doesn't match a paths: entry or attribute, you get no X-RateLimit-* at all. Clients that pre-emptively back off on Remaining will be blind on unconfigured routes.RateLimiterInterface — this is the cleanest extension point the bundle offers.storage_engine option is an enum-like switch inside the DI extension, not an arbitrary service ID in older versions. To add a backend, implement the storage interface and re-wire via a compiler pass, or decorate the limiter service. Check DependencyInjection/ before assuming a config knob exists.composer.json, read the CHANGELOG on every bump, and keep your own functional test asserting the 429 — if upstream stalls, that test tells you immediately and the swap to a hand-rolled listener is a day's work.^/health, ^/_profiler, and asset paths to an explicit bypass, or your monitoring will page you for rate limiting the monitor.How can I help you explore Laravel packages today?