Master Laravel Redis Lua scripting for deterministic rate limiting. Build a robust, distributed token bucket algorithm to protect your multi-tenant APIs.
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.
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.
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:
LUAlocal 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.
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:
PHPpublic 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 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.
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.
Learn how to implement atomic Laravel distributed locks using Redis to prevent race conditions and manage concurrency in your production job orchestration.