Throttling Symfony APIs With Rate Limiter and a Shared Cache
Use Symfony Rate Limiter with a shared Redis cache to enforce per-IP request limits on public APIs without cluttering controllers, and verify enforcement with 429 responses and profiler events.
09 Mar 2026, 04:18 UTC

Public endpoints need a throttle before they become a cost problem
Public APIs and form endpoints without throttling are easy targets for brute force, credential stuffing and accidental retry storms. The result is degraded performance, higher infrastructure cost and potential data exposure. The useful takeaway is to enforce limits declaratively and let Symfony’s Rate Limiter component handle token consumption against a shared cache, keeping controllers clean.
Rate Limiter keeps the limit logic out of the controller
The Rate Limiter component, stable since Symfony 5.3, provides a framework-integrated way to enforce request limits based on IP, user ID or any custom key. It uses the existing cache abstraction, so the limiter is just a policy on top of a cache pool. The #[RateLimit] attribute is available from Symfony 6.2 onward. On older LTS releases you must use the service based approach.
Configure a shared cache pool first
The limiter depends entirely on the cache store. A local filesystem pool gives each server its own counter and defeats global enforcement. Use a shared, persistent pool such as Redis for multi-instance deployments.
Install the component in the project root with write permission for composer:
composer require symfony/rate-limiter
Define a Redis cache pool in config/packages/framework.yaml:
framework:
cache:
pools:
redis_cache:
adapter: cache.adapter.redis
provider: 'redis://localhost'
Define a limiter service in the same file:
framework:
rate_limiter:
api_submit:
policy: sliding_window
limit: 5
interval: '1 minute'
rate_limit:
cache_pool: redis_cache
Clear the cache after changes:
php bin/console cache:clear
Risk: mis-configured cache replication can cause limits to be bypassed or applied too strictly. Capacity plan for the extra read/write per request.
Worked example: 5 requests per minute per IP on POST /submit
Inject RateLimiterFactory and create a limiter per request keyed by client IP. Consume a token and return 429 when exhausted.
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\HttpFoundation\Response;
class SubmitController
{
public function __construct(private RateLimiterFactoryInterface $apiSubmitLimiter) {}
public function __invoke(Request $request): Response
{
$key = $request->getClientIp();
$limiter = $this->apiSubmitLimiter->create($key);
$limit = $limiter->consume(1);
if (!$limit->isAccepted()) {
return new Response('Too Many Requests', 429, [
'Retry-After' => $limit->getRetryAfter()->getSeconds()
]);
}
// business logic here
return new Response('OK');
}
}
With the attribute style on Symfony 6.2+ you can annotate a controller action and let the framework handle the 429 response.
Trade-off: the limiter is only as good as the cache
Network latency to Redis adds overhead to each request. Cache misses or evictions reset counters unexpectedly. If the cache pool is not shared across instances, each node enforces its own limit. Monitor cache hit rate, latency and the rate_limiter events in the profiler.
Check it works without deploying to production
Start the dev server in the project root:
symfony server:start
Repeatedly call the endpoint from a terminal:
curl -i http://localhost:8000/submit
Expected checks: the first five calls within a minute succeed. Subsequent calls should return HTTP/1.1 429 Too Many Requests with a Retry-After header. In the Symfony profiler timeline, the rate_limiter event should show time spent consuming tokens and confirm the limiter was invoked.
Actionable closing: use a shared cache pool, define limiters in configuration, keep consumption in a thin guard around the action, and verify with repeated requests and profiler inspection before relying on it for production protection.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.