Programming Tutorials

Building a Sliding-Window Rate Limiter From Scratch in PHP

A working PHP rate limiter built on a sliding-window log, plus the specific bug in the naive fixed-window version that let a burst of traffic double the intended limit.

By Aissam Ait Ahmed Programming Tutorials 0 comments

The first rate limiter I ever shipped was a fixed-window counter: count requests per IP in a one-minute bucket, reject once the count passes a threshold, reset the bucket every sixty seconds. It passed every test I wrote and it still let a client through at roughly double the intended limit in production, because of a gap in the fixed-window design that only shows up right at the boundary between two windows. This is a walkthrough of building both versions in PHP, seeing the fixed-window bug reproduce on purpose, and fixing it with a sliding-window log instead.

The naive version: a fixed window counter

The fixed-window approach is the one everyone reaches for first because it's genuinely simple: pick a time bucket (say, one minute), count requests in that bucket, reset to zero when the bucket rolls over.

class FixedWindowLimiter
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly int $maxRequests = 100,
        private readonly int $windowSeconds = 60,
    ) {}

    public function allow(string $key): bool
    {
        $bucket = intdiv(time(), $this->windowSeconds);
        $redisKey = "ratelimit:{$key}:{$bucket}";

        $count = $this->redis->incr($redisKey);

        if ($count === 1) {
            $this->redis->expire($redisKey, $this->windowSeconds);
        }

        return $count <= $this->maxRequests;
    }
}

This looks correct, and for a while it behaves correctly. The bug shows up specifically at window boundaries: if a client sends 100 requests in the last second of one window, then immediately sends another 100 requests in the first second of the next window, both batches pass, because each one is evaluated against its own bucket's independent counter. That's 200 requests in about two seconds against a limit that was supposed to cap it at 100 per minute — the limiter did exactly what it was told, and what it was told wasn't quite the right rule.

Reproducing the boundary bug on purpose

Rather than take that on faith, here's a small script that simulates exactly that traffic pattern against the fixed-window limiter above:

$limiter = new FixedWindowLimiter($redis, maxRequests: 100, windowSeconds: 60);

// Simulate 100 requests at second 59 of the current minute
$allowedFirstBatch = 0;
for ($i = 0; $i < 100; $i++) {
    if ($limiter->allow('client-a')) {
        $allowedFirstBatch++;
    }
}

// Sleep past the window boundary, then send 100 more immediately
sleep(2);

$allowedSecondBatch = 0;
for ($i = 0; $i < 100; $i++) {
    if ($limiter->allow('client-a')) {
        $allowedSecondBatch++;
    }
}

echo "First batch allowed: {$allowedFirstBatch}\n";
echo "Second batch allowed: {$allowedSecondBatch}\n";
// Both print 100 — 200 requests total inside roughly two seconds

Running that against a real Redis instance prints 100 and 100, confirming the limiter let twice the configured rate through in a two-second span. If your rate limit exists for a real reason — protecting a downstream API with its own hard cap, or preventing a specific abuse pattern — that boundary gap is exactly the kind of thing an attacker (or just unlucky retry logic on a client) can trigger without even trying to be clever about it.

The fix: a sliding-window log

A sliding-window log tracks the actual timestamp of every request in the lookback period, rather than bucketing into fixed slots. To check whether a new request is allowed, count how many timestamps fall within the last N seconds from right now, not from the start of some fixed bucket.

class SlidingWindowLimiter
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly int $maxRequests = 100,
        private readonly int $windowSeconds = 60,
    ) {}

    public function allow(string $key): bool
    {
        $redisKey = "ratelimit:sliding:{$key}";
        $now = microtime(true);
        $windowStart = $now - $this->windowSeconds;

        // Drop any timestamps older than the current window
        $this->redis->zRemRangeByScore($redisKey, '-inf', (string) $windowStart);

        $currentCount = $this->redis->zCard($redisKey);

        if ($currentCount >= $this->maxRequests) {
            return false;
        }

        // Record this request's timestamp, keep the key alive
        $this->redis->zAdd($redisKey, $now, (string) $now);
        $this->redis->expire($redisKey, $this->windowSeconds);

        return true;
    }
}

This uses a Redis sorted set (ZADD/ZREMRANGEBYSCORE/ZCARD) where the score is the request's timestamp. Every check first trims anything older than the sliding window, then counts what's left. There's no fixed boundary for traffic to straddle, because the window is always measured relative to "right now," not relative to a clock-aligned bucket.

Running the exact same two-batch simulation against this version: the first batch allows 100 requests and then starts rejecting, and the second batch — arriving two seconds later, well inside the 60-second sliding window — is rejected almost entirely, because the sliding window still sees the first batch's requests as recent. That's the correct behavior: no more than 100 requests in any rolling 60-second span, measured continuously rather than reset on a clock tick.

The trade-off nobody mentions: memory versus fixed-window's near-zero cost

The sliding-window log isn't free. A fixed-window counter is one integer per key. A sliding-window log stores one entry per request within the lookback window, per key — for a high-limit endpoint (say, 10,000 requests per minute per API key), that's up to 10,000 sorted-set entries per active key at any moment. For most application-level rate limits (tens to low hundreds of requests per minute) this is a non-issue. For very high-throughput limits, it's worth knowing this cost exists before reaching for it by default, and a sliding-window counter (which approximates the sliding log using two adjacent fixed windows and a weighted average) is a common middle ground that trades a small amount of accuracy for the fixed window's low memory footprint.

  • Fixed window: cheapest, simplest, has the boundary-burst problem described above.
  • Sliding window log: fully accurate, memory cost scales with request volume within the window.
  • Sliding window counter (weighted average of two fixed windows): a practical middle ground used by several production rate-limiting libraries specifically to avoid the sliding log's memory growth.
  • Token bucket: a different model entirely — allows short bursts up to a bucket size while enforcing a steady average refill rate, useful when occasional bursts are fine but sustained high rates aren't.

Wiring it into a Laravel route as middleware

Rather than calling the limiter manually in every controller, wrapping it as middleware keeps the rate-limiting decision in one place:

class SlidingWindowRateLimit
{
    public function __construct(private readonly SlidingWindowLimiter $limiter) {}

    public function handle(Request $request, Closure $next)
    {
        $key = $request->ip();

        if (! $this->limiter->allow($key)) {
            return response()->json(['message' => 'Too many requests.'], 429);
        }

        return $next($request);
    }
}

Keying by IP is the simplest starting point and the one used above, but it's worth being deliberate about it — a shared office network or a mobile carrier's NAT can put many real users behind one IP, so an aggressive per-IP limit can penalize innocent users sharing that address. For an authenticated API, keying by API key or user ID instead of IP avoids that specific failure mode, at the cost of not protecting unauthenticated endpoints the same way.

Debugging which client is actually hitting the limit

When a 429 report comes in from a confused user, the first thing worth checking is whether the request is genuinely coming from the client they think it is, or from something sitting in front of them — a corporate proxy, a VPN exit node, or a shared NAT gateway aggregating dozens of unrelated users onto one address. Logging $request->ip() alongside the rejection and running that address through a proper IP lookup tool is usually enough to tell the difference in under a minute: a residential ISP address pointing at one region strongly suggests a single real client tripping the limit legitimately, while a data-center or known-VPN-provider result is a strong hint you're looking at either a proxy fronting many real users, or automated traffic that deserves the limit it's hitting.

That distinction changed how we handled one specific complaint: a user reported being rate-limited "for no reason," and the IP behind the request resolved to a well-known corporate VPN egress point, not a residential connection. That single IP was almost certainly the exit point for an entire office, all sharing one address, and the fix wasn't loosening the limit globally — it was keying that specific traffic pattern by a session token forwarded from the client instead of falling back to IP alone, so one office's combined traffic didn't collide with the intended per-person limit.

Testing it properly

The simulation script above is useful for exploring the behavior interactively, but a real test suite should assert the boundary case directly rather than relying on a printed comparison. If you haven't written this kind of time-dependent test before, the general approach — freezing or advancing a clock rather than calling real sleep() in a test — is exactly the kind of habit covered in writing your first automated test suite for a small PHP app, which is worth reading alongside this if rate limiting is the first place you're reaching for time-based test assertions.

public function test_it_rejects_the_101st_request_within_the_window(): void
{
    Carbon::setTestNow('2026-01-01 00:00:00');
    $limiter = new SlidingWindowLimiter($this->redis, maxRequests: 100, windowSeconds: 60);

    for ($i = 0; $i < 100; $i++) {
        $this->assertTrue($limiter->allow('test-client'));
    }

    $this->assertFalse($limiter->allow('test-client'));
}

What to actually reach for

For most application rate limiting, Laravel's built-in throttle middleware already implements a sensible algorithm and is the right default rather than hand-rolling your own — this walkthrough exists to make the underlying mechanics visible, not to argue you should ship a custom limiter instead of a maintained one. Where building your own genuinely earns its keep is a rate limit tied to specific business logic the generic middleware doesn't model well — per-API-key tiers with different limits, or a limit that needs to key on something other than IP or user ID. Even then, start from the sliding-window log design above rather than the fixed-window version; the boundary bug isn't a rare edge case, it's the default behavior of the simpler algorithm, and it's worth reproducing it once yourself, the way this post did, so it's not a surprise the first time real traffic finds it for you, in production, on a Friday afternoon, right before you'd planned to leave early for the week.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Programming Tutorials Free Resources Explore Tools