DEV Community

Cover image for Symfony RateLimiter: Token Bucket and Sliding Window Without Redis Glue
Gabriel Anhaia
Gabriel Anhaia

Posted on

Symfony RateLimiter: Token Bucket and Sliding Window Without Redis Glue


You know the code. Someone needed to cap login attempts, so they wrote a helper that does INCR on a Redis key, checks the count, sets a TTL, and calls it a rate limiter. Then someone else needed to cap password resets, and copied the helper. Then the payment webhook needed throttling, and the helper got a third copy with a subtly different TTL rule. Now there are four hand-rolled counters, none of them handle the window edge, and the one guarding your third-party API budget uses a fixed window that lets a client burn the entire hourly quota in the last two seconds of the window.

Symfony's RateLimiter component replaces all of that. It ships the token bucket and sliding window algorithms, keeps state in whatever cache pool you point it at, and exposes two verbs: consume() when you want to reject over-limit traffic, and reserve() when you want to slow it down instead. No Redis glue.

Install and pick a policy

composer require symfony/rate-limiter
Enter fullscreen mode Exit fullscreen mode

The component leans on symfony/lock and a cache pool, both already in a standard Symfony app. Limiters live in one config file:

# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        login:
            policy: 'sliding_window'
            limit: 5
            interval: '15 minutes'
        outbound_crm:
            policy: 'token_bucket'
            limit: 60
            rate: { interval: '1 minute', amount: 60 }
Enter fullscreen mode Exit fullscreen mode

Two policies, two jobs.

Sliding window is what you want for "no more than N requests per window." It approximates the count using the current and previous window, so a burst at the boundary can't double your effective limit the way a fixed window does. Use it to guard login, signup, password reset, anything where you reject the request outright once the count is hit.

Token bucket models a budget that refills over time. The limit is the bucket size (the burst you tolerate), and rate is how fast tokens drip back in. Sixty tokens, refilling sixty per minute, means a client can spend a burst of sixty immediately, then settles into one per second. This is the policy for pacing outbound work against an external quota, because it answers a different question: not "reject or accept," but "how long until a token frees up."

There is also fixed_window (cheap, one counter, boundary bursts allowed) and no_limit (a null object you can swap in per environment).

Injecting a limiter

Each named limiter becomes a factory service. Symfony wires it by argument name: a limiter called login injects wherever you type-hint RateLimiterFactoryInterface and name the argument $loginLimiter. The interface landed in Symfony 7.3; on older versions type-hint the concrete RateLimiterFactory instead.

namespace App\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;

final class LoginThrottle
{
    public function __construct(
        private readonly RateLimiterFactoryInterface $loginLimiter,
    ) {}

    public function assertAllowed(Request $request): void
    {
        $key = $request->getClientIp() . ':login';
        $limiter = $this->loginLimiter->create($key);

        $limiter->consume(1)->ensureAccepted();
    }
}
Enter fullscreen mode Exit fullscreen mode

create($key) scopes the limiter to one client. The key is yours to design: an IP, a user id, an API key, a tenant id, or a composite. consume() returns a RateLimit object. ensureAccepted() throws RateLimitExceededException when the budget is gone. If you would rather branch than catch, read the fields instead:

$limit = $limiter->consume(1);

if (!$limit->isAccepted()) {
    $retryAfter = $limit->getRetryAfter();     // \DateTimeImmutable
    $remaining = $limit->getRemainingTokens(); // int

    // set Retry-After and return 429
}
Enter fullscreen mode Exit fullscreen mode

Those two fields are what feed your 429 response and its Retry-After header. No manual TTL math.

Storage backends: where the counters live

The interesting part of "without Redis glue" is that the component does not care what Redis is. It talks to a PSR-6 cache pool. Point it at whichever pool suits the limiter:

# config/packages/cache.yaml
framework:
    cache:
        pools:
            cache.rate_limiter:
                adapter: cache.adapter.redis
                default_lifetime: 0

# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        login:
            policy: 'sliding_window'
            limit: 5
            interval: '15 minutes'
            cache_pool: 'cache.rate_limiter'
Enter fullscreen mode Exit fullscreen mode

Swap cache.adapter.redis for cache.adapter.array in tests, cache.adapter.filesystem for a single-node app, or a dedicated Redis pool so limiter churn never evicts your real application cache. The limiter code does not change. That is the seam that matters.

One thing to understand rather than copy: counters read-modify-write, so two worker processes hitting the same key at the same moment can race. The component guards against that with a lock, configured per limiter:

framework:
    rate_limiter:
        login:
            # ...
            lock_factory: 'lock.default.factory'
Enter fullscreen mode Exit fullscreen mode

Set lock_factory: null to turn it off when your storage already gives you atomic operations and you would rather not pay for a lock round-trip. Know which case you are in before you flip it.

reserve(): throttling instead of rejecting

consume() is a bouncer. It says yes or no and the request moves on. That is right at the HTTP edge, where a rejected caller can retry.

Outbound work is the opposite. A queue worker draining a backlog of CRM syncs should not throw away messages because the vendor allows sixty calls a minute. It should wait for the sixty-first. That is reserve().

namespace App\MessageHandler;

use App\Message\SyncContactMessage;
use App\Crm\CrmClient;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;

#[AsMessageHandler]
final class SyncContactHandler
{
    public function __construct(
        private readonly CrmClient $crm,
        private readonly RateLimiterFactoryInterface $outboundCrmLimiter,
    ) {}

    public function __invoke(SyncContactMessage $message): void
    {
        $limiter = $this->outboundCrmLimiter->create('crm-api');

        // block until a token frees up, but never
        // hold the worker hostage for more than 10s
        $limiter->reserve(1, 10)->wait();

        $this->crm->upsertContact($message->contactId);
    }
}
Enter fullscreen mode Exit fullscreen mode

reserve() returns a Reservation. Calling wait() sleeps the process until a token is free, then returns. The second argument caps the wait; if the token needs longer than ten seconds, it throws MaxWaitDurationExceededException before sleeping, and you let Messenger retry the message later rather than pinning a worker. The token bucket policy is what makes this smooth: the drip rate becomes your outbound pace, and the bucket size absorbs bursts when the queue suddenly fills.

A single shared key like 'crm-api' throttles every worker together against one global budget. Key it per tenant instead and each customer gets an independent quota out of the same limiter definition.

Where this sits relative to the domain

Rate limiting is a policy about traffic, not about your business rules. A Contact does not know that your CRM vendor sells sixty calls a minute, and it should not. That number is an accident of a third-party contract, the kind of fact that changes when someone renegotiates a plan.

So keep the limiter at the edges. Inbound: a controller, an event subscriber, or the security layer decides whether a request is allowed in. Outbound: an adapter, the class that already owns the HTTP client to the vendor, decides how fast you drain against their quota. The domain use case stays a plain method that syncs a contact and never imports a single class from Symfony\Component\RateLimiter. When the vendor doubles your allowance, you edit one YAML value and touch nothing that describes what your application does.

That placement is the whole point of decoupled design: infrastructure concerns like throttling, retries, and storage live in a thin outer ring, and the core stays unaware of which framework or vendor is wrapping it this year. Decoupled PHP is the book about drawing exactly those lines: what belongs in the adapter, what belongs in the use case, and how to keep the rate-limit number from leaking into code that should outlive it.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)