Rate Limiter
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:
- A bucket is created with an initial set of tokens;
- A new token is added to the bucket with a predefined frequency (e.g. every second);
- Allowing an event consumes one or more tokens;
- If the bucket still contains tokens, the event is allowed; otherwise, it's denied;
- 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
tokensoption, 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.
$anchorAtworks like the anchor_at option. tokenBucket(string $id, int $limit, \DateInterval|string $interval, int $amount = 1)-
Creates a token bucket limiter which adds
$amounttokens 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.