Rate limit your Symfony APIs!
Sometimes, you need to put some custom rate limits on your APIs! In this article I’ll show you how you can combine the symfony/rate-limiter
component and some usual controllers.
Section intitulée ratelimit-configurationRateLimit configuration
The goal here is to have the following rate limit configuration works thanks to PHP8 attributes on any route you want:
framework:
rate_limiter:
account_create:
policy: 'fixed_window'
limit: 5
interval: '60 minutes'
account_modify: # account activate, change profile
policy: 'fixed_window'
limit: 30
interval: '60 minutes'
This article isn’t about the component itself so I’ll recommend you to read the Symfony’s RateLimiter documentation if you want to understand how it works and how to create rules.
Section intitulée attributeAttribute
First of all, we need an attribute that we will use to declare routes that need to be rate limited. We will require a configuration key to identify which rate limit configuration we should take:
#[Attribute(Attribute::TARGET_METHOD)]
class RateLimiting
{
public function __construct(
public string $configuration,
) {
}
}
Section intitulée controllerController
Now let’s use our attribute on some controller:
#[RateLimiting('account_create')]
#[Route('/create', methods: ['POST'])]
public function createAccount(): JsonResponse
{
// your controller logic ...
}
And that’s all you need to do to declare a route as rate limited 👌
Section intitulée compilerpassCompilerPass
But before it works we need to make Symfony understand these attributes. So we need a CompilerPass to store all routes that have our attribute to avoid reflection at runtime:
class RateLimitingPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition(ApplyRateLimitingListener::class)) {
throw new \LogicException(sprintf('Can not configure non-existent service %s', ApplyRateLimitingListener::class));
}
$taggedServices = $container->findTaggedServiceIds('controller.service_arguments');
/** @var Definition[] $serviceDefinitions */
$serviceDefinitions = array_map(fn (string $id) => $container->getDefinition($id), array_keys($taggedServices));
$rateLimiterClassMap = [];
foreach ($serviceDefinitions as $serviceDefinition) {
$controllerClass = $serviceDefinition->getClass();
$reflClass = $container->getReflectionClass($controllerClass);
foreach ($reflClass->getMethods(\ReflectionMethod::IS_PUBLIC | ~\ReflectionMethod::IS_STATIC) as $reflMethod) {
$attributes = $reflMethod->getAttributes(RateLimiting::class);
if (\count($attributes) > 0) {
[$attribute] = $attributes;
$serviceKey = sprintf('limiter.%s', $attribute->newInstance()->configuration);
if (!$container->hasDefinition($serviceKey)) {
throw new \RuntimeException(sprintf(‘Service %s not found’, $serviceKey));
}
$classMapKey = sprintf('%s::%s', $serviceDefinition->getClass(), $reflMethod->getName());
$rateLimiterClassMap[$classMapKey] = $container->getDefinition($serviceKey);
}
}
}
$container->getDefinition(ApplyRateLimitingListener::class)->setArgument('$rateLimiterClassMap', $rateLimiterClassMap);
}
}
Here we get all controllers and we check on each method if they have our attribute and then we link the route to the corresponding rate limit service and add it in our cache.
Section intitulée listenerListener
Now that Symfony understands our attribute and cache it, we need an event listener to hook on the kernel.controller
event and check if our rate limit is fine or not.
class ApplyRateLimitingListener implements EventSubscriberInterface
{
public function __construct(
private TokenStorageInterface $tokenStorage,
/** @var RateLimiterFactory[] */
private array $rateLimiterClassMap,
private bool $isRateLimiterEnabled,
private RequestStack $requestStack,
private RoleHierarchyInterface $roleHierarchy,
) {
}
public function onKernelController(KernelEvent $event): void
{
if (!$this->isRateLimiterEnabled || !$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
/** @var string $controllerClass */
$controllerClass = $request->attributes->get('_controller');
$rateLimiter = $this->rateLimiterClassMap[$controllerClass] ?? null;
if (null === $rateLimiter) {
return; // no rate limit service was assigned for this controller
}
$token = $this->tokenStorage->getToken();
if ($token instanceof TokenInterface && in_array('ROLE_GLOBAL_MODERATOR', $this->roleHierarchy->getReachableRoleNames(($token->getRoleNames())))) {
return; // we ignore rate limit for site moderator & upper roles
}
$this->ensureRateLimiting($request, $rateLimiter, $request->getClientIp());
}
private function ensureRateLimiting(Request $request, RateLimiterFactory $rateLimiter, string $clientIp): void
{
$limit = $rateLimiter->create(sprintf('rate_limit_ip_%s', $clientIp))->consume();
$request->attributes->set('rate_limit', $limit);
$limit->ensureAccepted();
$user = $this->tokenStorage->getToken()?->getUser();
if ($user instanceof User) {
$limit = $rateLimiter->create(sprintf('rate_limit_user_%s', $user->getId()))->consume();
$request->attributes->set('rate_limit', $limit);
$limit->ensureAccepted();
}
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::CONTROLLER => ['onKernelController', 1024]];
}
}
In this example, I chose to ignore rate limits for our global moderator roles. For all other users I check the rate limit on two levels: IP then User if they are logged. That way we can avoid any user spamming from different IPs. These are business rules I use but you can custom it the way you want.
Also you can see that we share the rate limit service before each check: if there is a rate limit issue then an exception will be thrown (thanks to the ensureAccepted
method) and the second check won’t happen so we will have the correct rate limit service shared.
Section intitulée headersHeaders
Finally, to use that shared rate limit service, we can generate some headers to indicate how the rate limit went and other metrics:
final class RateLimitingResponseHeadersListener
{
public function onKernelResponse(ResponseEvent $event): void
{
if (($rateLimit = $event->getRequest()->attributes->get('rate_limit')) instanceof RateLimit) {
$event->getResponse()->headers->add([
'RateLimit-Remaining' => $rateLimit->getRemainingTokens(),
'RateLimit-Reset' => time() - $rateLimit->getRetryAfter()->getTimestamp(),
'RateLimit-Limit' => $rateLimit->getLimit(),
]);
}
}
}
I took the headers names from the RateLimit headers RFC, it’s still a draft but theses headers are already widely used.
And here we are – with only a few lines of code, you can add a rate limit to any route by adding your new RateLimiting
attribute!
Commentaires et discussions
Nos formations sur ce sujet
Notre expertise est aussi disponible sous forme de formations professionnelles !

Symfony
Formez-vous à Symfony, l’un des frameworks Web PHP les complet au monde

Symfony avancée
Découvrez les fonctionnalités et concepts avancés de Symfony
Ces clients ont profité de notre expertise

Dans le cadre du renouveau de sa stratégie digitale, Orpi France a fait appel à JoliCode afin de diriger la refonte du site Web orpi.com et l’intégration de nombreux nouveaux services. Pour effectuer cette migration, nous nous sommes appuyés sur une architecture de type “micro-services” à l’aide de PHP, Symfony, RabbitMQ, Elasticsearch et…

Afin de poursuivre son déploiement sur le Web, Arte a souhaité être accompagné dans le développement de son API REST “OPA” (API destinée à exposer les programmes et le catalogue vidéo de la chaine). En collaboration avec l’équipe technique Arte, JoliCode a mené un travail spécifique à l’amélioration des performances et de la fiabilité de l’API. Ces…

Nous avons développé une plateforme de site génériques autour de l’API Phraseanet. À l’aide de Silex de composants Symfony2, nous avons accompagné Alchemy dans la réalisation d’un site déclinable pour leurs clients. Le produit est intégralement configurable et supporte de nombreux systèmes d’authentification (Ldap, OAuth2, Doctrine ou anonyme).