Santaji GadePHP, Development15 hours ago10 Views

A practical guide to PHP rate limiting with Redis, covering token bucket and sliding window algorithms, proper 429 responses, and per tier API limits.
Table of Contents
ToggleOne script running in a loop can generate more traffic in a minute than a hundred real visitors send in a day. PHP rate limiting is what keeps that one script from taking down an endpoint everyone else still needs to use.
An API with no limit at all treats every caller the same, whether it is a real user or a script hammering the same route in a tight loop.
That single unbounded caller can exhaust database connections, fill up server memory, or simply push everyone else's requests to the back of the queue.
A flat file counter per IP address, the kind used earlier in this series for a contact form, works fine at small scale but does not survive across multiple servers.
Redis solves that gap. It gives every server in a cluster the same shared view of how many requests a caller has already made.
Cloudflare's own rate limiting documentation makes a similar point about shared state: a centralized store is what makes php rate limiting meaningful once traffic crosses more than one server.
Three algorithms cover almost every real world rate limiting need, and each makes a different tradeoff between simplicity and smoothness.
| Algorithm | Behavior | Weakness |
|---|---|---|
| Fixed window | Counts requests in fixed time blocks | Allows a burst right at the window boundary |
| Sliding window | Counts requests in a rolling time frame | Slightly more storage and computation |
| Token bucket | Refills tokens at a steady rate over time | More setup, but smooths bursts naturally |
Token bucket is the closest match to how most APIs actually want to behave, allowing a short burst while still enforcing a steady average rate.
OWASP's API Security Top 10 lists unrestricted resource consumption as a leading risk, and picking the right algorithm here is a direct mitigation for that exact threat category, which is why php rate limiting counts as a core defensive control rather than an optional nice to have.
PHP talks to Redis through the phpredis extension, which wraps the Redis commands used below in plain PHP method calls.
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->setOption(Redis::OPT_PREFIX, 'ratelimit:');
Setting a key prefix keeps rate limit keys clearly separated from anything else the same Redis instance might store for other parts of the application.
Redis's own command reference covers every method used below in full detail, and the PECL extension page lists the installation steps for whichever operating system is running the server.
Each caller gets a bucket that refills at a fixed rate, and a request only succeeds if a token is available to spend.
final class TokenBucket {
public function __construct(
private Redis $redis,
private int $capacity = 10,
private float $refillPerSecond = 0.5
) {}
public function allow(string $key): bool {
$now = microtime(true);
$state = $this->redis->get($key);
[$tokens, $lastRefill] = $state ? explode('|', $state) : [$this->capacity, $now];
$elapsed = $now - (float) $lastRefill;
$tokens = min($this->capacity, (float) $tokens + $elapsed * $this->refillPerSecond);
if ($tokens < 1) {
$this->redis->setex($key, 3600, sprintf('%f|%f', $tokens, $now));
return false;
}
$this->redis->setex($key, 3600, sprintf('%f|%f', $tokens - 1, $now));
return true;
}
}
Storing the token count and the last refill time together in one string keeps the read and write a single round trip to Redis instead of two separate calls that could race against each other.
The php.net manual for microtime() explains the float mode used here, which keeps the elapsed time calculation precise enough for sub second refill rates.
A sliding window counts requests inside a rolling time frame rather than resetting sharply at a fixed boundary, which avoids the fixed window's burst problem.
function allow_sliding(Redis $redis, string $key, int $limit, int $windowSeconds): bool {
$now = microtime(true);
$redis->zRemRangeByScore($key, 0, $now - $windowSeconds);
if ($redis->zCard($key) >= $limit) {
return false;
}
$redis->zAdd($key, $now, uniqid('', true));
$redis->expire($key, $windowSeconds);
return true;
}
A Redis sorted set stores one entry per request, scored by timestamp, so trimming entries older than the window is a single command instead of scanning a whole list.
A rejected request should tell the caller exactly what happened and when it is safe to try again, not just fail with a plain error.
if (!$bucket->allow($apiKey)) {
header('Retry-After: 2');
header('X-RateLimit-Limit: 10');
http_response_code(429);
echo json_encode(['error' => 'Too many requests']);
exit;
}
Status 429 is the standard code for this situation, and a well behaved client library already knows to back off and retry after seeing it.
RFC 6585 is the document that formally defines status 429, and it specifically recommends sending a Retry-After header so automated clients know exactly when it is safe to try again.
Not every route deserves the same limit. A search endpoint might tolerate heavy use, while a password reset endpoint should stay tight regardless of who is calling it.
$limits = [
'free' => new TokenBucket($redis, capacity: 10, refillPerSecond: 0.2),
'pro' => new TokenBucket($redis, capacity: 100, refillPerSecond: 2.0),
];
$bucket = $limits[$user['tier']] ?? $limits['free'];
Keying the bucket lookup by API key rather than IP address also means a shared office network never accidentally hits one caller's limit on another caller's behalf.
A limiter that has never actually been pushed past its limit in a test is a limiter nobody can be fully sure works correctly.
# fire 20 rapid requests and count how many get rejected
for i in {1..20}; do
curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/search
done | sort | uniq -c
Seeing a mix of 200 and 429 responses in that output confirms the bucket is enforcing its limit rather than silently allowing everything through.
A database can work but is slower for this purpose. Redis keeps everything in memory and supports the atomic operations a limiter needs, which is why it is the common choice.
A simple counter resets sharply at a fixed boundary, allowing a burst right at that edge. A token bucket refills gradually, which smooths traffic more naturally.
Both have a place. A web server or reverse proxy can block obvious floods early, while PHP level limiting allows finer control per user, per endpoint, or per API key.
They receive a 429 response with a Retry-After header telling them exactly how long to wait, so a well built client can recover automatically without any manual intervention.
No. It protects against volume based abuse specifically. Authentication, input validation, and authorization checks still need to run independently to cover other kinds of misuse.
Unbounded traffic from a single source degrades an API for all users.
Multiple servers can enforce the same limit without stepping on each other.
A gradual refill avoids the sharp edge a fixed window creates.
Clients already know to back off and retry after seeing this code.
A login route deserves a tighter limit than a general search route.
A limiter never pushed past its limit is one nobody has verified.
Pair php rate limiting with a wider security minded workflow to keep every endpoint fast and available for real users.









