PHP Rate Limiting: Token Bucket vs Sliding Window in Redis

#php rate limiting redis
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Most PHP rate limiting code starts life as a copied snippet. Somebody needed to stop a scraper, found an INCR with an EXPIRE attached, pasted it into a middleware, and moved on. It works, roughly, until the day a customer gets blocked at a minute boundary or two app servers race each other and let twice the traffic through. PHP rate limiting with Redis deserves an afternoon of real thought, because the two main algorithms behave differently in exactly the situations where a limiter matters.

This post compares the token bucket and the sliding window log, shows the Redis Lua scripts that make each one atomic, and walks through the Symfony wiring: key structure, response headers, per-route overrides, and the failure modes that only show up under production traffic.

How the Token Bucket Works

Picture a bucket that holds 60 tokens. Every request takes one token. The bucket refills at a constant rate, one token per second here, and never holds more than its capacity. A request that arrives to an empty bucket gets rejected.

Two properties fall out of this design. Sustained throughput is capped at the refill rate, 60 requests per minute in this example. And a client that has been quiet for a while can spend its saved tokens all at once: a burst of 60 requests in one second is allowed if the bucket is full.

For a public API, that burst allowance is usually what you want. Real clients are bursty. A dashboard loads and fires eight requests at once, a sync job wakes up and pushes a backlog. The token bucket absorbs those spikes while still holding everyone to the average rate over time. It is also cheap: the entire state is two numbers, the current token count and the timestamp of the last refill.

The weakness is precision. "60 per minute" as enforced by a token bucket means an average of one per second with bursts up to 60. Up to 120 requests can land inside a single 60 second span: the full bucket, plus a minute of refill. If a contract or a fragile downstream system defines the ceiling as a hard maximum in any window, that overshoot is a real problem.

How the Sliding Window Log Works

The sliding window log keeps a timestamped record of every accepted request, usually in a Redis sorted set. When a new request arrives, the limiter deletes all entries older than the window, counts what is left, and accepts only if the count is below the limit.

This gives an exact answer. With a limit of 60 per minute, no trailing 60 second span will ever contain more than 60 accepted requests. There is no burst multiplier, and none of the boundary artifacts a fixed window counter has, where 60 requests at 11:59:59 followed by 60 more at 12:00:01 is entirely legal.

The cost is memory and work per request. The sorted set holds one member per accepted request, so a tenant allowed 10,000 requests per hour can hold 10,000 entries under a single key. ZREMRANGEBYSCORE plus ZCARD plus ZADD is still fast, but it is more than updating two hash fields. For high limits, many teams switch to a sliding window counter, which approximates the log by weighting the previous fixed window. It is a fair compromise, and no longer exact.

PHP Rate Limiting in Redis: The Lua Scripts

Whichever algorithm you pick, the read-modify-write has to be atomic. Two PHP-FPM workers checking the same counter concurrently will both see one token left and both accept. You do not need a distributed lock for this. Redis runs Lua scripts atomically, so the check and the update happen as one unit.

The token bucket:

-- KEYS[1] = bucket key
-- ARGV = capacity, refill rate per second, now in ms, cost
local capacity = tonumber(ARGV[1])
local rate     = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local cost     = tonumber(ARGV[4])

local state  = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts     = tonumber(state[2])
if tokens == nil then
  tokens = capacity
  ts = now
end

tokens = math.min(capacity, tokens + (math.max(0, now - ts) / 1000) * rate)

local allowed = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate) * 2000)
return {allowed, math.floor(tokens)}

The sliding window log:

-- KEYS[1] = zset key
-- ARGV = window in ms, limit, now in ms, unique request id
local window = tonumber(ARGV[1])
local limit  = tonumber(ARGV[2])
local now    = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
  redis.call('ZADD', KEYS[1], now, ARGV[4])
  redis.call('PEXPIRE', KEYS[1], window)
  return {1, limit - count - 1}
end
return {0, 0}

Load each script once with SCRIPT LOAD and call it by SHA with EVALSHA; both phpredis and Predis make that a few lines. The request id in the second script only needs to be unique per request. A UUID works, and so does the request id you already carry in tracing headers.

Keys for Per-Tenant and Per-Endpoint Limits

A rate limit key should encode which policy applies, who is being limited, and what they are calling:

rl:{policy}:{tenant}:{scope}

That gives you rl:api-default:tenant_8231:global for the account-wide budget and rl:export:tenant_8231:POST_/v1/exports for an endpoint that needs its own. Because the policy name is part of the key, retiring a policy under a new name starts from clean state instead of inheriting counts from the old numbers.

Two things to avoid. Do not build keys from raw client input such as the API key string itself; hash it or use your internal tenant id, otherwise a hostile client can flood your keyspace with garbage. And on Redis Cluster, resist the urge to force all of a tenant's keys into one hash slot unless you really need multi-key operations. These scripts touch one key each, so plain keys spread the load evenly.

The Response Headers Clients Actually Read

Return enough information that a well-behaved client can back off without guessing. X-RateLimit-Limit carries the ceiling for the current window, X-RateLimit-Remaining what is left, and X-RateLimit-Reset when the budget refreshes. Use a Unix timestamp for Reset rather than a relative seconds value, and document that choice, because clients get this wrong in both directions. On a 429, add Retry-After in seconds. It is the only header of the group with an RFC behind it, and several HTTP clients honor it automatically.

For the token bucket, Remaining is the floored token count, and Reset is now plus the time to refill the next token. For the sliding window log, Reset is the oldest entry in the set plus the window length. If you want these exact rather than approximated, compute them inside the same Lua script and return them alongside the verdict.

Wiring It Into Symfony

Symfony ships a rate limiter component, and for a single service with modest traffic it does the job. Its storage stays consistent by wrapping the check in a lock rather than pushing it into Redis as one atomic operation, and under heavy concurrency that lock turns into its own bottleneck. Once you need per-tenant policies, exact headers, and more than one window per client, a small dedicated service behind an event subscriber ends up being less code than bending the component.

final class RateLimitSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private RedisRateLimiter $limiter,
        private PolicyRegistry $policies,
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => ['onRequest', 7],
            KernelEvents::RESPONSE => ['onResponse', 0],
        ];
    }

    public function onRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();
        $policy = $this->policies->forRoute($request->attributes->get('_route'));
        if ($policy === null) {
            return;
        }

        $tenant = $request->attributes->get('tenant_id')
            ?? 'ip:' . $request->getClientIp();
        $result = $this->limiter->consume($policy, $tenant, $request);
        $request->attributes->set('_rate_limit', $result);

        if (!$result->allowed) {
            $event->setResponse(
                new JsonResponse(['error' => 'rate_limited'], 429)
            );
        }
    }

    public function onResponse(ResponseEvent $event): void
    {
        $result = $event->getRequest()->attributes->get('_rate_limit');
        if ($result !== null) {
            $event->getResponse()->headers->add($result->toHeaders());
        }
    }
}

The registry resolves a policy per route: a global default, with overrides where a route needs tighter numbers or none at all. A PHP attribute on the controller reads nicely, and a plain config map does the same job. The request priority of 7 places the subscriber after routing (priority 32) and after the firewall (priority 8), so the tenant is already authenticated by the time the limiter runs, but before any controller work is spent on a request that will be rejected anyway.

Failure Modes Worth Deciding in Advance

Clock skew. If every app server passes its own microtime() into the script, servers with drifting clocks enforce slightly different windows, and a badly skewed one refills buckets early. The clean fix is to call TIME inside the Lua script so Redis is the single clock for everyone. Related: scripts run on the primary, and replication is asynchronous, so a failover can lose the last few hundred milliseconds of limiter state. The token bucket degrades gracefully there; the log may briefly over-admit. Neither is worth building a consensus system over.

Redis down. Decide fail open versus fail closed per policy, before the outage. For ordinary API traffic, failing open with an alert is usually right, since nobody wants a cache outage to escalate into a full API outage. Where the limiter is a security control, on login attempts, password resets, and expensive export endpoints, fail closed. Either way, wrap the Redis call in a timeout of a few tens of milliseconds so a hanging connection cannot stall every request in the fleet while it decides.

Multi-window limits. "100 per minute and 5,000 per day" means two keys with two TTLs, ideally checked in a single Lua script so one round trip settles both and a rejection by the day window refunds the minute window. Give each window its own key and let PEXPIRE match its span. Reusing one key for both windows is how daily limits end up resetting at odd hours nobody can explain.

Picking One

Default to the token bucket. It is cheaper, it matches how API clients behave, and its burst allowance is acceptable for almost all commercial traffic. Reach for the sliding window log when the ceiling is contractual, or when it protects a downstream system that cannot absorb bursts, and keep those limits low enough that the memory cost stays reasonable. Plenty of production systems run both: buckets for the general API, a log on the handful of endpoints where precision pays for itself.

The algorithm is only half of the job. How you set quotas per plan and communicate them to customers matters at least as much, and we covered that side in Rate Limiting a Multi-Tenant API.

If your limiter is still that copied snippet nobody has revisited since it shipped, that is a common find in growing codebases, and exactly the kind of thing we flag in a code audit. If you are designing an API platform and want rate limiting built in from the start rather than bolted on later, that is custom development work we do regularly. Write to hello@wolf-tech.io, or see how we work at wolf-tech.io.