Token bucket rate limiter with Redis and PHP
Implement a token bucket rate limiter using Redis and Lua scripts in PHP
This guide shows you how to implement a distributed token bucket rate limiter using Redis and Lua scripts in PHP with the Predis client library.
Overview
Rate limiting is a critical technique for controlling the rate at which operations are performed. Common use cases include:
- Limiting API requests per user or IP address
- Preventing abuse and protecting against denial-of-service attacks
- Ensuring fair resource allocation across multiple clients
- Throttling background jobs or batch operations
The token bucket algorithm is a popular rate limiting approach that allows bursts of traffic while maintaining an average rate limit over time. This guide covers the PHP implementation using the Predis client library, taking advantage of PHP's associative arrays and Composer-based dependency management.
How it works
The token bucket algorithm works like a bucket that holds tokens:
- Initialization: The bucket starts with a maximum capacity of tokens
- Refill: Tokens are added to the bucket at a constant rate (for example, 1 token per second)
- Consumption: Each request consumes one token from the bucket
- Decision: If tokens are available, the request is allowed; otherwise, it's denied
- Capacity limit: The bucket never exceeds its maximum capacity
This approach allows for burst traffic (using accumulated tokens) while enforcing an average rate limit over time.
Why use Redis?
Redis is ideal for distributed rate limiting because:
- Atomic operations: Lua scripts execute atomically, preventing race conditions
- Shared state: Multiple application servers can share the same rate limit counters
- High performance: In-memory operations provide microsecond latency
- Automatic expiration: Keys can be set to expire automatically (though not used in this implementation)
The Lua script
The core of this implementation is a Lua script that runs atomically on the Redis server. This ensures that checking and updating the token bucket happens in a single operation, preventing race conditions in distributed environments.
Here's how the script works:
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local refill_interval = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- Get current state or initialize
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])
-- Initialize if this is the first request
if tokens == nil then
tokens = capacity
last_refill = now
end
-- Calculate token refill
local time_passed = now - last_refill
local refills = math.floor(time_passed / refill_interval)
if refills > 0 then
tokens = math.min(capacity, tokens + (refills * refill_rate))
last_refill = last_refill + (refills * refill_interval)
end
-- Try to consume a token
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
-- Update state
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
-- Return result: allowed (1 or 0) and remaining tokens
return {allowed, tokens}
Script breakdown
- State retrieval: Uses
HMGETto fetch the current token count and last refill time from a hash - Initialization: On first use, sets tokens to full capacity
- Token refill calculation: Computes how many tokens should be added based on elapsed time
- Capacity enforcement: Uses
math.min()to ensure tokens never exceed capacity - Token consumption: Decrements the token count if available
- State update: Uses
HMSETto save the new state - Return value: Returns both the decision (allowed/denied) and remaining tokens
Why atomicity matters
Without atomic execution, race conditions could occur:
- Double spending: Two requests could read the same token count and both succeed when only one should
- Lost updates: Concurrent updates could overwrite each other's changes
- Inconsistent state: Token count and refill time could become desynchronized
Using EVAL or EVALSHA ensures the entire operation executes atomically, making it safe for distributed systems.
Installation
Install the Predis client library using Composer:
composer require predis/predis
Using the PHP class
The TokenBucket class provides a simple interface for rate limiting
(source):
<?php
require __DIR__ . '/vendor/autoload.php';
use Predis\Client;
// Create a Redis connection
$redis = new Client([
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
]);
// Create a rate limiter: 10 requests per second
$limiter = new TokenBucket(
redis: $redis,
capacity: 10, // Maximum burst size
refillRate: 1, // Add 1 token per interval
refillInterval: 1.0 // Every 1 second
);
// Check if a request should be allowed
$result = $limiter->allow('user:123');
if ($result['allowed']) {
echo "Request allowed. {$result['remaining']} tokens remaining.\n";
// Process the request
} else {
echo "Request denied. Rate limit exceeded.\n";
// Return 429 Too Many Requests
}
The allow() method returns an associative array with two keys: allowed (boolean) and remaining (number of tokens left in the bucket).
Configuration parameters
- capacity: Maximum number of tokens in the bucket (controls burst size)
- refillRate: Number of tokens added per refill interval
- refillInterval: Time in seconds between refills
For example:
capacity: 10, refillRate: 1, refillInterval: 1.0allows 10 requests per second with bursts up to 10capacity: 100, refillRate: 10, refillInterval: 1.0allows 10 requests per second with bursts up to 100capacity: 60, refillRate: 1, refillInterval: 60.0allows 1 request per minute with bursts up to 60
Rate limit keys
The key parameter identifies what you're rate limiting. Common patterns:
- Per user:
user:{userId}- Limit each user independently - Per IP address:
ip:{ipAddress}- Limit by client IP - Per API endpoint:
api:{endpoint}:{userId}- Different limits per endpoint - Global:
global:api- Single limit shared across all requests
Script caching with EVALSHA
The PHP implementation uses EVALSHA for optimal performance. On first use, the Lua script is loaded into Redis with SCRIPT LOAD, and subsequent calls use the cached SHA1 hash. If the script is evicted from the cache, the class automatically falls back to EVAL and reloads the script.
// The class handles script caching automatically.
// First call loads the script, subsequent calls use EVALSHA.
$result1 = $limiter->allow('user:123'); // Uses EVAL + caches
$result2 = $limiter->allow('user:123'); // Uses EVALSHA (faster)
Running the demo
Get the source files
The demo consists of two PHP files. Download them from the php source folder on GitHub, or grab them with curl:
mkdir rate-limiter-demo && cd rate-limiter-demo
BASE=https://raw.githubusercontent.com/redis/docs/main/content/develop/use-cases/rate-limiter/php
curl -O $BASE/TokenBucket.php
curl -O $BASE/demo_server.php
Start the demo server
A demonstration web server is included to show the rate limiter in action (source):
# Install dependencies
composer require predis/predis
# Run the demo server
php -S localhost:8080 demo_server.php
The demo provides an interactive web interface where you can:
- Submit requests and see them allowed or denied in real-time
- View the current token count
- Adjust rate limit parameters dynamically
- Test different rate limiting scenarios
The demo assumes Redis is running on localhost:6379 but you can specify a different host and port using the REDIS_HOST and REDIS_PORT environment variables:
REDIS_HOST=myhost REDIS_PORT=6380 php -S localhost:8080 demo_server.php
Visit http://localhost:8080 in your browser to try it out.
Response headers
It's common to include rate limit information in HTTP response headers:
$capacity = 10;
$refillInterval = 1.0;
$limiter = new TokenBucket(
redis: $redis,
capacity: $capacity,
refillRate: 1,
refillInterval: $refillInterval,
);
$result = $limiter->allow("user:{$userId}");
// Add standard rate limit headers
header('X-RateLimit-Limit: ' . $capacity);
header('X-RateLimit-Remaining: ' . (int) $result['remaining']);
header('X-RateLimit-Reset: ' . (int) (time() + $refillInterval));
if (!$result['allowed']) {
header('Retry-After: ' . (int) $refillInterval);
http_response_code(429);
echo json_encode(['error' => 'Too Many Requests']);
exit;
}
Customization
Using as PSR-15 middleware
You can wrap the rate limiter as PSR-15 middleware for use with frameworks like Slim or Laravel:
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class RateLimitMiddleware implements MiddlewareInterface
{
public function __construct(
private TokenBucket $limiter,
private \Closure $keyFn,
) {}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface {
$key = ($this->keyFn)($request);
$result = $this->limiter->allow($key);
if (!$result['allowed']) {
$response = new \Slim\Psr7\Response(429);
$response->getBody()->write(
json_encode(['error' => 'Rate limit exceeded'])
);
return $response->withHeader('Content-Type', 'application/json');
}
$response = $handler->handle($request);
return $response->withHeader(
'X-RateLimit-Remaining',
(string) (int) $result['remaining']
);
}
}
// Apply per-IP rate limiting
$middleware = new RateLimitMiddleware(
$limiter,
fn(ServerRequestInterface $req) => 'ip:' . $req->getServerParams()['REMOTE_ADDR']
);
Error handling
The allow() method may throw a Predis\Connection\ConnectionException if the Redis connection is lost. Wrap calls in try/catch blocks for production use:
try {
$result = $limiter->allow('user:123');
// Handle result
} catch (\Predis\Connection\ConnectionException $e) {
error_log('Rate limiter error: ' . $e->getMessage());
// Fail open or closed depending on your policy
}
Alternative rate limiting algorithms
The token bucket algorithm above handles most use cases but Redis supports other rate limiter patterns that might fit your requirements better. The table below lists four other algorithms alongside token bucket and summarizes their features:
| Algorithm | Memory | Accuracy | Burst behavior | Best for |
|---|---|---|---|---|
| Token bucket | 1 key (hash) | Exact | Controlled bursts | APIs with bursty traffic |
| Fixed window counter | 1 key (string) | Approximate | 2x burst at boundaries | Simple API limits |
| Sliding window log | O(n) entries | Exact | No bursts | High-value APIs, audit trails |
| Sliding window counter | 2 keys (string) | Near-exact | Smoothed boundaries | General-purpose APIs |
| Leaky bucket (policing) | 1 key (hash) | Exact | No bursts | Strict no-burst enforcement |
The sections below give example implementations of these other algorithms.
The three time-based algorithms call redis.call('TIME') inside the Lua
script to derive the current timestamp from the Redis server clock. This
eliminates clock drift when the limiter runs across multiple application
servers. The fixed window counter reads no clock: the key's TTL defines
the window.
Fixed window counter
Counts requests within discrete, non-overlapping time intervals.
Simplest algorithm — one key per window, one EVAL round trip.
<?php
use Predis\Client;
const FIXED_WINDOW_SCRIPT = <<<'LUA'
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, window)
end
local ttl = redis.call('PTTL', key)
if count > limit then
return {0, ttl}
end
return {1, ttl}
LUA;
/**
* @return array{allowed: bool, retry_after_ms: int}
*/
function fixedWindowAllow(Client $redis, string $key, int $limit, int $windowSeconds): array
{
[$allowed, $ttl] = $redis->eval(FIXED_WINDOW_SCRIPT, 1, $key, $limit, $windowSeconds);
return [
'allowed' => $allowed === 1,
'retry_after_ms' => $allowed === 1 ? 0 : (int) $ttl,
];
}
Trade-off: A client can make 2x requests by sending limit requests
at the end of one window and limit requests at the start of the next.
Sliding window log
Records the exact timestamp of every request in a sorted set. Provides a true rolling window with no boundary bursts.
<?php
use Predis\Client;
const SLIDING_WINDOW_LOG_SCRIPT = <<<'LUA'
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local member = ARGV[3]
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local cutoff = now - window
redis.call('ZREMRANGEBYSCORE', key, '-inf', cutoff)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window * 2)
return {1, 0}
end
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_after_ms = 0
if oldest[2] then
retry_after_ms = math.floor((tonumber(oldest[2]) + window - now) * 1000)
end
return {0, retry_after_ms}
LUA;
function slidingWindowLogAllow(Client $redis, string $key, int $limit, int $windowSeconds): array
{
$member = bin2hex(random_bytes(16));
[$allowed, $retryAfterMs] = $redis->eval(
SLIDING_WINDOW_LOG_SCRIPT, 1, $key, $limit, $windowSeconds, $member
);
return ['allowed' => $allowed === 1, 'retry_after_ms' => (int) $retryAfterMs];
}
Trade-off: Memory grows O(n) with request volume. Not ideal for high-volume, high-cardinality rate limiting.
Sliding window counter
Blends two fixed-window counters using a weighted average to approximate a true sliding window. Near-exact accuracy with the same low memory footprint as a fixed window. The two keys use hash tags so they map to the same slot in Redis Cluster.
<?php
use Predis\Client;
const SLIDING_WINDOW_COUNTER_SCRIPT = <<<'LUA'
local base = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local window_num = math.floor(now / window)
local elapsed = (now % window) / window
local curr_key = base .. ':' .. window_num
local prev_key = base .. ':' .. (window_num - 1)
local prev = tonumber(redis.call('GET', prev_key) or 0)
local curr = tonumber(redis.call('GET', curr_key) or 0)
local estimate = prev * (1 - elapsed) + curr
if estimate >= limit then
return {0, 0}
end
local new_count = redis.call('INCR', curr_key)
if new_count == 1 then
redis.call('EXPIRE', curr_key, window * 2)
end
return {1, 0}
LUA;
function slidingWindowCounterAllow(Client $redis, string $key, int $limit, int $windowSeconds): array
{
[$allowed] = $redis->eval(
SLIDING_WINDOW_COUNTER_SCRIPT, 1, '{' . $key . '}', $limit, $windowSeconds
);
return ['allowed' => $allowed === 1];
}
Trade-off: The weighted estimate may let slightly more or fewer requests through than the exact limit. Negligible for most apps.
Leaky bucket (policing)
A virtual bucket fills with incoming requests and drains at a fixed rate. If the bucket is full, requests are rejected immediately. This is the policing variant — requests are allowed or denied instantly with no delay.
<?php
use Predis\Client;
const LEAKY_BUCKET_SCRIPT = <<<'LUA'
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local leak_rate = tonumber(ARGV[2])
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local data = redis.call('HGETALL', key)
local level = 0
local last_leak = now
if #data > 0 then
for i = 1, #data, 2 do
if data[i] == 'level' then
level = tonumber(data[i+1])
elseif data[i] == 'last_leak' then
last_leak = tonumber(data[i+1])
end
end
end
local elapsed = now - last_leak
level = math.max(0, level - elapsed * leak_rate)
if level + 1 > capacity then
return {0, math.floor((level + 1 - capacity) / leak_rate * 1000)}
end
level = level + 1
local ttl = math.ceil(capacity / leak_rate) + 1
redis.call('HSET', key, 'level', level, 'last_leak', now)
redis.call('EXPIRE', key, ttl)
return {1, 0}
LUA;
function leakyBucketAllow(Client $redis, string $key, int $capacity, float $leakRate): array
{
[$allowed, $retryAfterMs] = $redis->eval(
LEAKY_BUCKET_SCRIPT, 1, $key, $capacity, $leakRate
);
return ['allowed' => $allowed === 1, 'retry_after_ms' => (int) $retryAfterMs];
}
Trade-off: Overflow traffic is rejected immediately. Clients must
handle 429 Too Many Requests and retry with backoff.
Learn more
- EVAL command - Execute Lua scripts
- EVALSHA command - Execute cached Lua scripts
- Lua scripting - Introduction to Redis Lua scripting
- HMGET command - Get multiple hash fields
- HMSET command - Set multiple hash fields
- PHP client - Redis PHP client documentation