Skip to content

Rate Limiter

Edit this page

A "rate limiter" controls how frequently some event (e.g. an HTTP request or a login attempt) is allowed to happen. Rate limiting is commonly used as a defensive measure to protect services from excessive use (intended or not) and maintain their availability. It's also useful to control your internal or outbound processes (e.g. limit the number of simultaneously processed messages).

Symfony uses these rate limiters in built-in features like login throttling, which limits how many failed login attempts a user can make in a given period of time, but you can use them for your own features too.

Danger

By definition, the Symfony rate limiters require Symfony to be booted in a PHP process. This makes them not useful to protect against DoS attacks. Such protections must consume the least resources possible. Consider using Apache mod_ratelimit, NGINX rate limiting, Caddy HTTP rate limit module (also supported by FrankenPHP) or proxies (like AWS or Cloudflare) to prevent your server from being overwhelmed.

Rate Limiting Policies

Symfony's rate limiter implements some of the most common policies to enforce rate limits: fixed window, sliding window, token bucket.

Fixed Window Rate Limiter

This is the simplest technique and it's based on setting a limit for a given interval of time (e.g. 5,000 requests per hour or 3 login attempts every 15 minutes).

In the diagram below, the limit is set to "5 tokens per hour". Each window starts at the first hit (i.e. 10:15, 11:30 and 12:30). As soon as there are 5 hits (the blue squares) in a window, all others will be rejected (red squares).

Its main drawback is that resource usage is not evenly distributed in time and it can overload the server at the window edges. In this example, there were 6 accepted requests between 11:00 and 12:00.

This is more significant with bigger limits. For instance, with 5,000 requests per hour, a user could make 4,999 requests in the last minute of some hour and another 5,000 requests during the first minute of the next hour, making 9,999 requests in total in two minutes and possibly overloading the server. These periods of excessive usage are called "bursts".

Sliding Window Rate Limiter

The sliding window algorithm is an alternative to the fixed window algorithm designed to reduce bursts. This is the same example as above, but then using a 1 hour window that slides over the timeline:

As you can see, this removes the edges of the window and would prevent the 6th request at 11:45.

To achieve this, the rate limit is approximated based on the current window and the previous window.

For example: the limit is 5,000 requests per hour; a user made 4,000 requests the previous hour and 500 requests this hour. 15 minutes into the current hour (25% of the window) the hit count would be calculated as: 75% * 4,000 + 500 = 3,500. At this point in time the user can only do 1,500 more requests.

The math shows that the closer the last window is, the more the hit count of the last window will affect the current limit. This will make sure that a user can do 5,000 requests per hour but only if they are evenly spread out.

Note

When configuring your limiter, you can use the anchor_at option to define a reference datetime used by the policy to align windows on a calendar. When set, windows reset at anchor_at + (n * interval) instead of starting on the first hit.

8.1

The anchor_at option was introduced in Symfony 8.1.

Token Bucket Rate Limiter

This technique implements the token bucket algorithm, which defines continuously updating the budget of resource usage. It roughly works like this:

  1. A bucket is created with an initial set of tokens;
  2. A new token is added to the bucket with a predefined frequency (e.g. every second);
  3. Allowing an event consumes one or more tokens;
  4. If the bucket still contains tokens, the event is allowed; otherwise, it's denied;
  5. If the bucket is at full capacity, new tokens are discarded.

The below diagram shows a token bucket of size 4 that is filled with a rate of 1 token per 15 minutes:

This algorithm handles more complex back-off burst management. For instance, it can allow a user to try a password 5 times and then only allow 1 every 15 minutes (unless the user waits 75 minutes and they will be allowed 5 tries again).

Installation

Before using a rate limiter for the first time, run the following command to install the associated Symfony Component in your application:

1
$ composer require symfony/rate-limiter

Configuration

The following example creates two different rate limiters for an API service, to enforce different levels of service (free or paid):

1
2
3
4
5
6
7
8
9
10
11
12
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        anonymous_api:
            # use 'sliding_window' if you prefer that policy
            policy: 'fixed_window'
            limit: 100
            interval: '60 minutes'
        authenticated_api:
            policy: 'token_bucket'
            limit: 5000
            rate: { interval: '15 minutes', amount: 500 }

Note

The value of the interval option must be a number followed by any of the units accepted by the PHP date relative formats (e.g. 3 seconds, 10 hours, 1 day, etc.)

In the anonymous_api limiter, after making the first HTTP request, you can make up to 100 requests in the next 60 minutes. After that time, the counter resets and you have another 100 requests for the following 60 minutes.

In the authenticated_api limiter, after making the first HTTP request you are allowed to make up to 5,000 HTTP requests in total, and this number grows at a rate of another 500 requests every 15 minutes. If you don't make that number of requests, the unused ones don't accumulate (the limit option prevents that number from being higher than 5,000).

Tip

All rate-limiters are tagged with the rate_limiter tag, so you can find them with a tagged iterator or locator.

Rate Limiting in Action

Rate Limiting a Controller

8.1

The #[RateLimit] attribute was introduced in Symfony 8.1.

The simplest way to apply rate limiting to a controller is to add the RateLimit attribute. It references a limiter defined in the framework.rate_limiter configuration and, when the limit is exceeded, throws a TooManyRequestsHttpException, which Symfony turns into a 429 Too Many Requests response with a Retry-After header:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// src/Controller/ApiController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\RateLimit;
use Symfony\Component\Routing\Attribute\Route;

class ApiController extends AbstractController
{
    // if you don't define a 'key' argument, the key of the rate limiter
    // bucket uses the client IP, HTTP method and path
    #[RateLimit('api')]
    public function index(): JsonResponse
    {
        // ...
    }

    // restrict the limit to specific HTTP methods
    // (e.g. only rate-limit write operations; reads are unrestricted)
    #[RateLimit('api', methods: ['POST', 'PUT', 'PATCH', 'DELETE'])]
    public function edit(): JsonResponse
    {
        // ...
    }

    // build the bucket key from a submitted form field
    // (e.g. group requests by the submitted email so password-reset attempts
    // can't be spread across multiple IPs to bypass the limit)
    #[RateLimit('per_account', key: new Expression('request.request.get("email")'))]
    public function resetPassword(): Response
    {
        // ...
    }

    // build the bucket key from a route parameter; Symfony replaces the
    // {id} placeholder with the value of the 'id' route parameter
    #[Route('/user/{id}/messages', methods: ['POST'])]
    #[RateLimit('per_account', key: 'user-{id}')]
    public function sendMessage(): Response
    {
        // ...
    }

    // the 'key' argument also accepts a closure; it receives the controller
    // arguments, the current Request and the controller instance (in that order)
    #[RateLimit(
        'per_account',
        key: static function (array $args, Request $request): string {
            return $request->query->getString('email');
        },
    )]
    public function resetPasswordViaLink(): Response
    {
        // ...
    }

    // key the limit on the authenticated user instead of the client IP
    #[RateLimit('per_account', key: new Expression(
        'current_user().getUserIdentifier()'
    ))]
    public function profile(): Response
    {
        // ...
    }

    // specify the number of tokens consumed per request (defaults to 1)
    // (e.g. to take into account more expensive operations)
    #[RateLimit('api', tokens: 5)]
    public function export(): JsonResponse
    {
        // ...
    }
}

When the key is a string, Symfony replaces each {name} placeholder in it with the value of the request attribute of the same name (e.g. a route parameter). That attribute must exist and its value must be a string, an integer or a Stringable object.

8.2

The {name} placeholders in the key argument were introduced in Symfony 8.2.

When SecurityBundle is installed, the key expression can call any of the security functions, such as current_user() in the previous example.

8.2

Support for the security functions in these expressions was introduced in Symfony 8.2.

Note

Closures in attributes require PHP 8.5 or higher and must be static (arrow functions aren't allowed). On older PHP versions, pass an Expression to the key argument, as in the resetPassword() example.

Tip

The #[RateLimit] attribute can also be placed on the controller class to apply the same limit to every action.

You can stack several #[RateLimit] attributes on the same controller. Each limit is evaluated independently and all of them must pass for the request to be accepted. This is convenient to combine a broad per-IP quota with a stricter per-action limit:

1
2
3
4
5
6
#[RateLimit('global')]                    // applies to all methods
#[RateLimit('login', methods: ['POST'])]  // additional limit on POST /login
public function login(): Response
{
    // ...
}

Symfony checks the #[RateLimit] attributes before resolving the controller arguments, so rejected requests don't run any argument value resolver (e.g. one that queries the database to get an entity). Symfony also checks them before other attributes such as #[IsGranted], even if you write those attributes above #[RateLimit]. For example, when #[IsGranted] denies access to a user without the required role, that request still counts toward the rate limit.

The exception are limits whose key or condition is a closure or an expression that uses the args variable, because they need the controller arguments. Symfony checks those limits after resolving the arguments, in the same order as the attributes. For example, when #[IsGranted] is above #[RateLimit], the requests that it denies don't count toward the rate limit. If a key or condition only needs a route parameter, read it from the request (e.g. with a {id} placeholder or request.attributes.get('id')) instead of reading the related entity from args, so Symfony can check the limit before resolving the arguments.

8.2

Checking the rate limits before resolving the controller arguments was introduced in Symfony 8.2.

For full control over the limiter logic, inject the rate limiter as a service in your controllers and services.

Applying a Rate Limit Conditionally

8.2

The if argument was introduced in Symfony 8.2.

Sometimes a limit must only apply to some requests, such as the ones made by anonymous users. The methods argument only filters requests by HTTP method, so use the if argument for any other condition. For example, use the anonymous_api and authenticated_api limiters configured earlier to give authenticated users a larger quota than anonymous users:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/Controller/ApiController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\RateLimit;

class ApiController extends AbstractController
{
    // requests that match none of the conditions aren't rate limited,
    // so these two attributes use opposite conditions to cover all requests

    // applies only to anonymous users
    #[RateLimit('anonymous_api', if: new Expression('!is_authenticated()'))]
    // applies only to authenticated users, with a separate quota per user
    // (Symfony only evaluates the key when the condition is true, so
    // current_user() always returns a user here)
    #[RateLimit(
        'authenticated_api',
        key: new Expression('current_user().getUserIdentifier()'),
        if: new Expression('is_authenticated()'),
    )]
    public function index(): JsonResponse
    {
        // ...
    }
}

Like key, the if argument accepts an expression or a closure (with the same variables and arguments), but it must return a boolean. When it returns false, Symfony skips that limiter and the request doesn't consume any of its tokens.

Warning

Don't base conditions on data sent by the client (e.g. the presence of a header) because clients could then choose which limiter applies to them. Use the security functions instead, which rely on the authenticated user.

Exposing the Rate Limit in the Response Headers

8.2

The exposeHeaders option was introduced in Symfony 8.2.

API clients usually need to know how much of their quota is left. Set the exposeHeaders option of the attribute to true to add the X-RateLimit-* headers to the responses of the rate-limited controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/Controller/ApiController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\RateLimit;

class ApiController extends AbstractController
{
    #[RateLimit('api', exposeHeaders: true)]
    public function index(): JsonResponse
    {
        // ...
    }
}

Both the successful responses and the 429 Too Many Requests responses then include the following headers:

X-RateLimit-Limit
The number of requests allowed by the limiter. When the attribute defines the tokens option, this is the limit of the rate limiter divided by the number of tokens consumed per request (rounded down).
X-RateLimit-Remaining
The number of requests left before the limiter rejects them.
X-RateLimit-Reset
The Unix timestamp when the limiter is back to its full capacity.

These values are specific to each client, so Symfony also marks the response as private to prevent shared HTTP caches from storing it. Symfony doesn't add any of these headers when the response already contains one of them, or when the limiter never gets back to its full capacity (e.g. when using the no_limiter policy).

When several #[RateLimit] attributes apply to the same request, the headers describe the exposed limiter with the fewest requests left. However, if a limiter rejects the request, the headers always describe that limiter, and if that limiter doesn't expose them, the response includes no X-RateLimit-* headers at all. This lets you combine a public limit with a hidden one:

1
2
3
4
5
6
#[RateLimit('api', exposeHeaders: true)] // clients see the state of this one
#[RateLimit('api_abuse')]                // clients never see this one
public function index(): JsonResponse
{
    // ...
}

Reading the Consumed Rate Limit

8.2

The RateLimitValueResolver was introduced in Symfony 8.2.

Sometimes you need the state of the rate limit inside the controller, for example to tell users how many more messages they can send. Type-hint a controller argument with the RateLimit class to get the rate limit consumed by the #[RateLimit] attributes of the controller. Symfony doesn't consume any extra tokens to inject it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/Controller/ContactController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\RateLimit as RateLimitAttribute;
use Symfony\Component\RateLimiter\RateLimit;

class ContactController extends AbstractController
{
    #[RateLimitAttribute('contact_form')]
    public function send(RateLimit $rateLimit): Response
    {
        // ... send the message

        $this->addFlash('success', sprintf(
            'Message sent. You can send %d more messages.',
            $rateLimit->getRemainingTokens()
        ));

        return $this->redirectToRoute('contact');
    }
}

When several #[RateLimit] attributes apply to the same request, the argument gets the rate limit with the fewest requests left, regardless of the exposeHeaders option.

When no attribute applies to the request (because the controller doesn't define any or because their methods or if arguments exclude the request), the argument gets its default value or null if it's nullable. Otherwise, Symfony throws an exception.

Reacting to Exceeded Rate Limits

8.2

The RateLimitExceededEvent was introduced in Symfony 8.2.

Whenever the #[RateLimit] attribute rejects a request, it dispatches a RateLimitExceededEvent object right before throwing the TooManyRequestsHttpException. Listen to it to log the rejection, report a metric or send an alert:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/EventListener/RateLimitExceededListener.php
namespace App\EventListener;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\RateLimiter\Event\RateLimitExceededEvent;

#[AsEventListener]
class RateLimitExceededListener
{
    public function __construct(
        private LoggerInterface $logger,
    ) {
    }

    public function __invoke(RateLimitExceededEvent $event): void
    {
        $this->logger->warning('Rate limit "{limiter}" exceeded for key "{key}".', [
            'limiter' => $event->getLimiterName(),
            'key' => $event->getKey(),
        ]);
    }
}

Note

This event is dispatched for observation only: it doesn't allow overriding the decision made by the rate limiter or the exception that will be thrown.

Login throttling also dispatches this event when it rejects a login attempt. In that case, getLimiterName() returns the ID of the rate limiter service used by the firewall (security.login_throttling.<firewall name>.limiter by default, or the value of the limiter option if you define your own) and getKey() returns null. That's because the default login rate limiter combines two limits (one per IP address and another one per username and IP address), so no single key identifies the rejected attempt.

Note

Rejections coming from a rate limiter you inject and consume manually don't dispatch this event.

Injecting the Rate Limiter Service

After having configured one or more rate limiters, use the #[Target] attribute to inject a specific rate limiter in any service or controller. Symfony creates a target with the same name as the rate limiter.

For example, to inject the anonymous_api limiter defined earlier:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/Controller/ApiController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;

class ApiController extends AbstractController
{
    public function index(
        #[Target('anonymous_api')] RateLimiterFactoryInterface $rateLimiter
    ): Response
    {
        // ...
    }
}

Using the Rate Limiter Service

After having injected the rate limiter in any service or controller, call the consume() method to try to consume a given number of tokens. For example, this controller uses the previous rate limiter to control the number of requests to the API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// src/Controller/ApiController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;

class ApiController extends AbstractController
{
    public function index(
        Request $request,
        #[Target('anonymous_api')] RateLimiterFactoryInterface $rateLimiter,
    ): Response
    {
        // create a limiter based on a unique identifier of the client
        // (e.g. the client's IP address, a username/email, an API key, etc.)
        $limiter = $rateLimiter->create($request->getClientIp());

        // the argument of consume() is the number of tokens to consume
        // and returns an object of type Limit
        if (false === $limiter->consume(1)->isAccepted()) {
            throw new TooManyRequestsHttpException();
        }

        // you can also use the ensureAccepted() method - which throws a
        // RateLimitExceededException if the limit has been reached
        // $limiter->consume(1)->ensureAccepted();

        // to reset the counter
        // $limiter->reset();

        // ...
    }
}

Note

In a real application, instead of checking the rate limiter in all the API controller methods, create an event listener or subscriber for the kernel.request event and check the rate limiter once for all requests.

Wait until a Token is Available

Instead of dropping a request or process when the limit has been reached, you might want to wait until a new token is available. This can be achieved using the reserve() method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// src/Controller/ApiController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;

class ApiController extends AbstractController
{
    public function registerUser(
        Request $request,
        #[Target('authenticated_api')] RateLimiterFactoryInterface $rateLimiter,
    ): Response
    {
        $apiKey = $request->headers->get('apikey');
        $limiter = $rateLimiter->create($apiKey);

        // this blocks the application until the given number of tokens can be consumed
        $limiter->reserve(1)->wait();

        // optional, pass a maximum wait time (in seconds), a MaxWaitDurationExceededException
        // is thrown if the process has to wait longer. E.g. to wait at most 20 seconds:
        //$limiter->reserve(1, 20)->wait();

        // ...
    }

    // ...
}

The reserve() method is able to reserve a token in the future. Only use this method if you're planning to wait, otherwise you will block other processes by reserving unused tokens.

Note

Not all strategies allow reserving tokens in the future. These strategies may throw a ReserveNotSupportedException when calling reserve().

In these cases, you can use consume() together with wait(), but there is no guarantee that a token is available after the wait:

1
2
3
4
5
// ...
do {
    $limit = $limiter->consume(1);
    $limit->wait();
} while (!$limit->isAccepted());

Exposing the Rate Limiter Status

When using a rate limiter in APIs, it's common to include some standard HTTP headers in the response to expose the limit status (e.g. remaining tokens, when new tokens will be available, etc.). When using the #[RateLimit] attribute, Symfony can add these headers for you.

Otherwise, use the RateLimit object returned by the consume() method (also available via the getRateLimit() method of the Reservation object returned by the reserve() method) to get the value of those HTTP headers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// src/Controller/ApiController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;

class ApiController extends AbstractController
{
    public function index(Request $request, RateLimiterFactoryInterface $anonymousApiLimiter): Response
    {
        $limiter = $anonymousApiLimiter->create($request->getClientIp());
        $limit = $limiter->consume();
        $headers = [
            'X-RateLimit-Remaining' => $limit->getRemainingTokens(),
            'X-RateLimit-Limit' => $limit->getLimit(),
        ];

        if (false === $limit->isAccepted()) {
            throw new TooManyRequestsHttpException(
                $limit->getRetryAfter()->getTimestamp() - time(),
                headers: $headers,
            );
        }

        // ...

        $response = new Response('...');
        $response->headers->add($headers);

        return $response;
    }
}

Note

The TooManyRequestsHttpException sets the standard Retry-After header automatically using the value passed as its first argument, so you don't need to include that header yourself.

Tip

Use the getResetAt() method of the RateLimit object to build the X-RateLimit-Reset header. It returns the moment when the limiter is back to its full capacity, or null when the limiter never gets back to it (e.g. with the no_limiter policy).

8.2

The RateLimit::getResetAt() method was introduced in Symfony 8.2.

Storing Rate Limiter State

All rate limiter policies require storing their state (e.g. how many hits were already made in the current time window). By default, all limiters use the cache.rate_limiter cache pool created with the Cache component. This means that every time you clear the cache, the rate limiter will be reset.

Tip

By default, this pool stores its data in the filesystem, so the state of the limiters also persists between tests and the requests made in one test can reach the limit in the next tests. Clear the pool before each test:

1
2
3
4
5
6
7
8
// tests/Controller/LoginControllerTest.php
protected function setUp(): void
{
    // create the client before getting the container, because
    // createClient() fails if the kernel is already booted
    $this->client = static::createClient();
    static::getContainer()->get('cache.rate_limiter')->clear();
}

You can use the cache_pool option to override the cache used by a specific limiter (or even create a new cache pool for it):

1
2
3
4
5
6
7
8
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        anonymous_api:
            # ...

            # use the "cache.anonymous_rate_limiter" cache pool
            cache_pool: 'cache.anonymous_rate_limiter'

Note

Instead of using the Cache component, you can also implement a custom storage. Create a PHP class that implements the StorageInterface and set the storage_service setting of each limiter to the service ID of this class.

Resetting the Rate Limiters in Tests

The cache.rate_limiter pool isn't cleared between tests, so a test that exhausts a limiter would make the following tests start with that limiter already exhausted. That's why, when the framework.test option is enabled, Symfony clears this pool every time the kernel is shut down with KernelTestCase::ensureKernelShutdown(). This happens at the end of each test and when booting a new kernel, so all limiters start fresh in every test. The state is still kept across all the requests made with the same kernel.

If a test relies on the tokens consumed by a previous test (or before a kernel shutdown in the same test), update it to consume those tokens itself.

Limiters that use their own cache_pool or storage_service aren't reset, because that pool or storage can contain other data. If you need a fresh state for them, clear the cache pool (or reset your custom storage) yourself, for example at the start of your test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// tests/Controller/ApiControllerTest.php
namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class ApiControllerTest extends WebTestCase
{
    public function testTooManyRequests(): void
    {
        $client = static::createClient();

        // the pool defined with the "cache_pool" option of your limiter
        static::getContainer()->get('cache.anonymous_rate_limiter')->clear();

        // ...
    }
}

8.2

The automatic reset of the cache.rate_limiter pool in tests was introduced in Symfony 8.2.

Using Locks to Prevent Race Conditions

Race conditions can happen when the same rate limiter is used by multiple simultaneous requests (e.g. three servers of a company hitting your API at the same time). Rate limiters use locks to protect their operations against these race conditions.

By default, if the lock component is installed, Symfony uses the global lock configured by framework.lock, but you can use a specific named lock via the lock_factory option (or none at all):

1
2
3
4
5
6
7
8
9
10
11
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        anonymous_api:
            # ...

            # use the "lock.rate_limiter.factory" for this limiter
            lock_factory: 'lock.rate_limiter.factory'

            # or don't use any lock mechanism
            lock_factory: null

Compound Rate Limiter

You can configure multiple rate limiters to work together:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        two_per_minute:
            policy: 'fixed_window'
            limit: 2
            interval: '1 minute'
        five_per_hour:
            policy: 'fixed_window'
            limit: 5
            interval: '1 hour'
        contact_form:
            policy: 'compound'
            limiters: [two_per_minute, five_per_hour]

Then, inject and use as normal:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/Controller/ContactController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;

class ContactController extends AbstractController
{
    public function registerUser(
        Request $request,
        #[Target('contact_form')] RateLimiterFactoryInterface $rateLimiter,
    ): Response
    {
        $limiter = $rateLimiter->create($request->getClientIp());

        if (false === $limiter->consume(1)->isAccepted()) {
            // either of the two limiters has been reached
        }

        // ...
    }

    // ...
}

Giving a Fixed Key to Some Sub-Limiters

8.2

The option to give a fixed key to a sub-limiter was introduced in Symfony 8.2.

All sub-limiters use the key passed to create(), so in the example above both of them count requests per client IP. To give a fixed key to some of them, define the sub-limiters as a map instead of a list. A sub-limiter with a fixed key counts the requests of all clients together:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        two_per_minute:
            policy: 'fixed_window'
            limit: 2
            interval: '1 minute'
        global_quota:
            policy: 'fixed_window'
            limit: 5000
            interval: '1 hour'
        contact_form:
            policy: 'compound'
            limiters:
                # no key: uses the key passed to create()
                two_per_minute: ~
                # fixed key: shared by all clients
                global_quota: { key: 'global' }

The controller shown above doesn't change: two_per_minute still allows two submissions per minute to each client IP, while global_quota also limits the contact form to 5,000 submissions per hour in total.

Creating Rate Limiters at Runtime

8.2

The RateLimiterBuilder was introduced in Symfony 8.2.

Limiters configured under framework.rate_limiter must be defined in advance and injected one by one. When a service needs many different limiters, or when the limits are only known at runtime, inject the RateLimiterBuilder service instead and create the limiters in your code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/Controller/ApiController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterBuilder;

class ApiController extends AbstractController
{
    public function registerUser(
        Request $request,
        RateLimiterBuilder $limiterBuilder,
    ): Response
    {
        $limiter = $limiterBuilder
            // arguments are: id, limit and interval
            ->slidingWindow('register', 10, '10 minutes')
            ->create($request->getClientIp());

        if (false === $limiter->consume()->isAccepted()) {
            // ...
        }

        // ...
    }
}

Each builder method returns a RateLimiterFactoryInterface, so the limiters it creates are used in the same way as the configured ones. There's one method per rate limiting policy:

slidingWindow(string $id, int $limit, \DateInterval|string $interval)
Creates a sliding window limiter.
fixedWindow(string $id, int $limit, \DateInterval|string $interval, \DateTimeInterface|string|null $anchorAt = null)
Creates a fixed window limiter. $anchorAt works like the anchor_at option.
tokenBucket(string $id, int $limit, \DateInterval|string $interval, int $amount = 1)
Creates a token bucket limiter which adds $amount tokens back every $interval.
compound(RateLimiterFactoryInterface ...$factories)
Combines the given factories into a single compound limiter that enforces all of them at once.
noop()
Creates a factory of NoLimiter objects, which accept everything. Use it to disable rate limiting conditionally without changing the calling code.

Intervals accept the same string values as the interval configuration option (e.g. '10 minutes'), and also \DateInterval objects:

1
$factory = $limiterBuilder->slidingWindow('api', 100, new \DateInterval('PT1H'));

The $id argument is the namespace under which the limiter state is stored. Two limiters built with the same ID (and the same storage) share their state. This also lets a limiter created by the builder share its state with a limiter configured under framework.rate_limiter: give both the same ID.

Combine several factories to enforce multiple limits at once:

1
2
3
4
$limiter = $limiterBuilder->compound(
    $limiterBuilder->fixedWindow('contact_form_per_minute', 2, '1 minute'),
    $limiterBuilder->fixedWindow('contact_form_per_hour', 5, '1 hour'),
)->create($request->getClientIp());

Or use a no-op limiter to skip rate limiting for some users:

1
2
3
4
5
$factory = $this->isGranted('ROLE_ADMIN')
    ? $limiterBuilder->noop()
    : $limiterBuilder->slidingWindow('api', 100, '1 hour');

$limiter = $factory->create($this->getUser()->getId());

Like configured limiters, the builder stores its state in the cache.rate_limiter pool and uses the global lock configured by framework.lock (when the Lock component is installed). Use the builder option to change this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        builder:
            # use the "lock.rate_limiter.factory" lock factory
            # (or "null" to not use any lock mechanism)
            lock_factory: 'lock.rate_limiter.factory'

            # use the "cache.builder_rate_limiter" cache pool
            cache_pool: 'cache.builder_rate_limiter'

            # or use a custom storage service (this takes
            # precedence over the "cache_pool" option)
            storage_service: 'App\RateLimiter\CustomStorage'

        limiters:
            anonymous_api:
                # ...

Note

builder is a reserved key in the shorthand configuration syntax, where limiters are defined directly under rate_limiter. If you need a limiter called builder, define it under the explicit limiters key.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version