Go to content
Web development

Implementing Rate Limiting with Symfony RateLimiter

When your Symfony application exposes endpoints to the public internet, some clients will send more requests than you want to serve. A login form attracts password guessing, a public API gets scraped, and a webhook receiver can be flooded by a misconfigured caller. Rate limiting sets a maximum number of requests a client can make in a time window and rejects the excess, typically with an HTTP Too Many Requests response.

Symfony’s RateLimiter component handles the counting for you. It stores a counter for each client in a cache. It offers three built-in strategies: fixed window, which resets the count at set intervals; sliding window, which counts over a rolling period; and token bucket, which refills tokens at a steady rate. It also integrates with the framework so a controller method can declare its own limit with the #[RateLimit] attribute added in Symfony 8.1.

You need to choose which strategy matches your traffic, where to store the counters so every application instance sees the same state, and what to do when a client exceeds the quota. The sections below walk through each choice and the behaviors that follow, such as the token bucket’s initial burst (a new client can use the whole quota at once) and the fixed window’s boundary problem (a client can double the rate at the reset point).

How the component counts requests

The component’s main interface is LimiterInterface, which the Symfony source defines. You don’t manage counters directly. You inject a limiter factory, call create() with a key that identifies the caller, and call consume() on the returned limiter. The key can be an IP address, a user ID, or an API key, whichever distinguishes one client from another in your domain.

consume() returns a result object rather than a boolean. The interface originally returned true or false, which threw away the information you need to respond well, such as how long the caller must wait. The current object tells you whether the request is accepted and, when it isn’t, how many seconds to wait before retrying.

The component stores each key’s request count in a cache pool, a named storage area backed by files, Redis, Memcached, or similar. Because it builds on the Symfony Cache system, the same limiter works on a single server with filesystem cache or across a cluster with a shared pool.

The component also uses a lock to coordinate concurrent requests, so two requests don’t read the same counter value and both pass. The lock is optional. A change merged during the 5.3 development cycle lets you configure a limiter without one. Without the lock, racing requests can occasionally slip through together, but you avoid the overhead of acquiring a lock on every request. Use a lock when exact enforcement matters more than throughput, and skip it when a rare overcount is acceptable.

Choosing between the three strategies

Each of the three built-in policies counts differently, and the choice changes how clients experience bursts.

Fixed window divides time into discrete intervals and allows up to the limit in each. The weakness is the boundary: a client can use the full quota at the end of one window and the full quota at the start of the next, effectively doubling the rate for a short burst.

Sliding window smooths that problem by counting over a rolling window instead of a hard boundary. It blends the previous window’s count with the current window’s count as time passes, so a burst cannot straddle a reset point the way it can with fixed windows. The cost is slightly more state and computation per request.

Token bucket gives each client a bucket that holds up to the limit in tokens and refills at a steady rate. A request consumes a token, and the bucket rejects requests when it is empty. A fresh bucket starts full, so a new client can consume the entire capacity at once, then must wait for refills.

The token bucket’s burst behavior is discussed in a GitHub issue. If your goal is flood prevention for a brand-new caller, token bucket lets that first burst through by design.

Token bucket also supports reserving tokens in the future. If you prefer to delay a request rather than reject it, you can call reserve() and block until the tokens become available, which is useful for queueing work behind a rate limit. Fixed window cannot do this because it has no notion of tokens accumulating over time, as the limiter state discussion notes.

For fixed windows that should reset at a natural boundary, Symfony 8.1 added the anchor_at option. You can anchor a window to a calendar date such as the first of a month, so a monthly quota aligns with a billing cycle instead of starting when the first request arrives. That matters for API plans and fiscal periods, as the Symfony 8.1 release post explains.

Configuring a limiter and its storage

Rate limiters are defined in the framework configuration under framework.rate_limiter. Each entry names a limiter and sets its strategy and quota. The token bucket example from the issue above looks like this:

framework:
    rate_limiter:
        authenticated_api:
            strategy: token_bucket
            limit: 5000
            rate:
                interval: '15 minutes'
                amount: 500

A fixed window limiter uses interval instead of rate:

framework:
    rate_limiter:
        anonymous_api:
            strategy: fixed_window
            limit: 100
            interval: '60 minutes'

You can point each limiter at a specific cache pool. Keeping a dedicated pool for rate limits, separate from other cached data, lets you tune TTLs and clear counters without touching the rest of the cache.

The storage choice determines how the limiter handles more traffic. Filesystem cache works when every request reaches the same server. Behind a load balancer, each instance would enforce its own separate counter, and a client could spread requests across instances to dodge the limit. A shared pool backed by Redis or Memcached makes every instance read and write the same counters, at the cost of a network round trip per request.

Calling the limiter from your code

Symfony exposes each configured limiter as a service named limiter.<name>. Inject that factory into the controller and bind the constructor argument to the service, since the RateLimiterFactory type alone does not tell Symfony which limiter you mean.

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactory;

final class LoginController
{
    public function __construct(
        private RateLimiterFactory $loginLimiter,
    ) {
    }

    public function __invoke(Request $request): Response
    {
        $limiter = $this->loginLimiter->create($request->getClientIp());
        $result = $limiter->consume(1);

        if (!$result->isAccepted()) {
            throw new TooManyRequestsHttpException($result->getRetryAfter());
        }

        // authenticate the user
    }
}

consume(1) uses one token. Pass a larger number when a single request represents several units of work, for example when one endpoint call triggers multiple expensive operations.

The rejection path is where you make a product decision. Throwing TooManyRequestsHttpException produces the standard HTTP Too Many Requests response, and the Fusonic RateLimitBundle does the same when a route limit is exceeded. Alternatively, when the limiter supports reservation, you can wait for tokens instead of rejecting the request.

Declarative limits with the #[RateLimit] attribute

Symfony 8.1 added the #[RateLimit] attribute because writing consume() calls in every controller is repetitive. A PHP attribute is declarative metadata attached to a class or method. You place it on a controller method and the kernel enforces the limit before the controller runs, as described in the Symfony 8.1 release post. In the 8.2 branch the attribute lives in Symfony\Component\HttpKernel\Attribute\RateLimit.

use Symfony\Component\HttpKernel\Attribute\RateLimit;

#[RateLimit(limit: 5)]
public function createOrder(): Response
{
    // at most 5 requests per window
}

The attribute supports per-method restrictions, so you can limit only POST requests and leave GET requests alone. It also accepts an expression that computes the key from the request, which lets you scope the limit to an authenticated user rather than a shared IP. And it supports multi-token consumption, so one request can draw down several tokens at once.

The attribute suits flat quotas on individual endpoints. Call the limiter directly when you need to vary the key, respond differently to rejection, or consume a variable number of tokens based on the request. You can then branch on the result object.

When the built-in policies don’t fit

The three policies cover steady quotas, but not every traffic pattern. The same GitHub issue that documented the token bucket burst also asked for a pattern that none of the three policies can express. Token bucket gives a burst then a steady refill, and the window policies give a flat quota per interval. If you need a limit that changes shape over time, combine two limiters, such as a small token bucket for short-term bursts plus a sliding window for the sustained average, or build a custom policy.

Building a custom policy requires an extension point. The RateLimiterFactory was not originally designed to be extended, and a proposal in a GitHub issue suggested an abstract factory plus factory tagging so third-party code could register new strategies. Check whether the component version you use exposes those hooks before you plan to fork it.

If you prefer to declare limits per route in configuration and react through events instead of writing limiter calls in each controller, the Fusonic RateLimitBundle is a maintained alternative. It works through Symfony’s event system, emitting events when attempts are updated, when a limit is exceeded, and when attempts reset. Its configuration defines routes with a limit and a period, backed by a cache provider such as cache.app, and its example throws TooManyRequestsHttpException when a route limit is exceeded.

Exposing rate limit state to clients

Clients behave better when they can see how close they are to the limit. An IETF internet-draft proposes three standard response headers: RateLimit-Limit for the quota, RateLimit-Remaining for what’s left, and RateLimit-Reset for when the window resets. Supporting these headers lets a well-behaved client back off before it hits a Too Many Requests response.

The discussion around exposing limiter state comes with warnings. The values you read from the limiter can be stale by the time you render them, because another request may consume tokens in between, so treat them as informational. Never trust a value a client sends back to you. And consider what the headers reveal: precise remaining counts tell an attacker exactly how many attempts they have left, which can make abuse easier rather than harder. Send headers for client convenience, but keep the enforcement in the limiter.

More in Web development Back to top ↑