Back to Blog
LaravelPHPJune 21, 20264 min read

Laravel Redis Lua Scripting for Deterministic Rate Limiting

Master Laravel Redis Lua scripting for deterministic rate limiting. Build a robust, distributed token bucket algorithm to protect your multi-tenant APIs.

LaravelRedisLuaRate LimitingAPIArchitecturePerformancePHPBackend

Last month, we hit a wall with our standard middleware during a flash sale. Our multi-tenant Laravel application was struggling to enforce strict rate limits across several distributed worker nodes, and the default RateLimiter facade was suffering from classic race conditions under high concurrency. We needed a solution that was atomic, fast, and didn't rely on the application layer to mediate state.

We landed on implementing a custom Token Bucket algorithm using Redis Lua scripting. This approach allows us to push the logic down to the data layer, ensuring that the entire check-and-decrement operation happens in a single, non-interruptible transaction.

Why Distributed Rate Limiting Requires Lua

When you're running multiple instances of a Laravel application, memory-based rate limiting is useless. Even the standard Redis driver often performs two distinct commands—a get followed by a set—to update bucket counts. In a high-traffic environment, two concurrent requests can both read the same bucket value before either has written the update, effectively doubling your allowed throughput.

By using Redis Lua scripting, we wrap the entire logic into one command. Redis executes the script atomically, meaning no other command can run while our rate limiter is calculating the remaining tokens. It’s the difference between a loose synchronization strategy and a truly deterministic system.

Designing the Token Bucket

The Token Bucket algorithm is elegant because it handles bursts gracefully. You define a capacity (the maximum tokens) and a refill_rate (how many tokens are added per second).

Here’s the simplified Lua logic we deployed:

LUA
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

-- Calculate refill
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + (elapsed * refill_rate))

if tokens >= 1 then
    redis.call('HMSET', key, 'tokens', tokens - 1, 'last_refill', now)
    redis.call('EXPIRE', key, 10) -- Keep the key alive briefly
    return 1
else
    return 0
end

This script runs entirely inside Redis. By shifting this load, we offloaded significant CPU pressure from our PHP-FPM workers. If you’re dealing with similar scaling hurdles, you might also find value in API Request Batching: Reduce Network Overhead and Latency to minimize the number of round-trips your app makes to Redis.

Integrating with Laravel Multi-Tenancy

In a multi-tenant environment, you can't have one global bucket. We key our Redis entries by tenant_id and endpoint_path. If you're managing complex database isolation, make sure you've already handled Laravel multi-tenancy: Implementing Isolated Redis Cache Architectures to ensure your key-spaces don't collide.

To hook this into Laravel, we create a custom Middleware:

PHP
public function handle($request, Closure $next)
{
    $tenantId = $request->user()->tenant_id;
    $key = "rate_limit:{$tenantId}:{$request->path()}";

    $allowed = Redis::eval(
        $this->luaScript, 
        1, 
        $key, 
        100, #6A9955">// Capacity
        10,  #6A9955">// Refill per second
        time()
    );

    if (!$allowed) {
        throw new TooManyRequestsHttpException();
    }

    return $next($request);
}

The Trade-offs of Deterministic Rate Limiting

The primary trade-off here is observability. Because the logic is hidden inside a Lua script, debugging why a tenant is being throttled becomes slightly more difficult than inspecting a standard Eloquent model. You lose the ability to easily "log" the state changes without adding redis.log() calls inside the script, which can impact performance if overused.

We also toyed with the idea of using API Rate Limiting at the Edge: Protecting Your Downstream Services but decided against it for this specific project because we needed to perform tenant-specific logic that our edge provider didn't support.

What I’d Do Differently

Looking back, we hard-coded the refill rates in the Lua script for the first iteration. That was a mistake. When a tenant requested a higher tier, we had to redeploy code to update the script. Now, we pass the capacity and refill rates as arguments to the script, allowing us to fetch these values from the database or cache dynamically.

I’m still not entirely satisfied with how we handle "burst" logs. Currently, we just return a 429, but we’re considering implementing a more granular notification system for our top-tier tenants. If we were building this today, I’d probably look into Laravel Workflow: Architecting Asynchronous State Machines for Reliability to handle the alerting side-effects without blocking the request-response cycle.

Rate limiting in a distributed system is never truly "finished." It’s an ongoing battle between performance, accuracy, and complexity. Start simple, use Lua for the heavy lifting, and keep your keys isolated.

Similar Posts