DEV Community

Cover image for Laravel Rate Limiting: Named Limiters, Redis, and the 429 You Forgot
Gabriel Anhaia
Gabriel Anhaia

Posted on

Laravel Rate Limiting: Named Limiters, Redis, and the 429 You Forgot


You add throttle:60,1 to your API routes and call it done. Six weeks later a customer's mobile app starts retrying a failed request in a tight loop. Your throttle kicks in and returns a 429. The mobile client doesn't know what a 429 is, doesn't read the Retry-After header (because you never checked it was there), and hammers the endpoint anyway. Your Redis fills with counter keys. Your logs fill with the same request. The rate limiter is working exactly as configured and the outage is still yours.

Rate limiting in Laravel is two lines to turn on and a dozen decisions to get right. The decisions are named limiters, the key you count by, the shape of the 429, and what backs the counter. Here is the version that holds up.

Named limiters instead of inline numbers

The throttle:60,1 syntax hardcodes the limit at the route. That works until you need the limit to depend on the user, the plan, or the time of day. Named limiters move the logic into one place you can read and test.

Register them in boot() on AppServiceProvider:

<?php

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        RateLimiter::for('api', function (Request $request) {
            return Limit::perMinute(60)->by(
                $request->user()?->id ?: $request->ip()
            );
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Attach it by name:

Route::middleware('throttle:api')->group(function () {
    Route::get('/orders', [OrderController::class, 'index']);
    Route::post('/orders', [OrderController::class, 'store']);
});
Enter fullscreen mode Exit fullscreen mode

The whole point is ->by(). Without it, every request shares one global bucket and one noisy client throttles everyone. Keying by user ID when authenticated and IP when not is the sane default. Fall back to IP, never to nothing.

The key is a design decision, not a detail

by() decides who shares a bucket. Get it wrong and you either throttle innocent users together or hand each attacker a fresh quota.

Tier the limit by plan and hand unlimited traffic to the accounts that pay for it:

RateLimiter::for('api', function (Request $request) {
    $user = $request->user();

    if ($user?->onPlan('enterprise')) {
        return Limit::none();
    }

    return Limit::perMinute($user ? 120 : 20)
        ->by($user?->id ?: $request->ip());
});
Enter fullscreen mode Exit fullscreen mode

Limit::none() skips the limiter for that request. Anonymous traffic gets 20, signed-in users get 120, enterprise gets out of the way.

One trap: keying auth endpoints by IP alone. A login route throttled per IP lets an attacker spray one password across thousands of accounts from a single address without ever tripping the limit, because the count that matters is per-account, not per-IP. Count both.

RateLimiter::for('login', function (Request $request) {
    return [
        Limit::perMinute(20)->by($request->ip()),
        Limit::perMinute(5)->by($request->input('email')),
    ];
});
Enter fullscreen mode Exit fullscreen mode

Two windows: burst and sustained

A single perMinute limit can't say "quick bursts are fine, but not all day." Return an array and every limit is checked. Use distinct prefixes in by() so the two counters never collide.

RateLimiter::for('api', function (Request $request) {
    $key = $request->user()?->id ?: $request->ip();

    return [
        Limit::perMinute(60)->by("min:{$key}"),
        Limit::perDay(10_000)->by("day:{$key}"),
    ];
});
Enter fullscreen mode Exit fullscreen mode

The minute limit absorbs a legitimate burst. The daily limit stops a slow scraper that stays under 60/min but never stops. Miss the second one and someone pulls your entire catalog at 59 requests a minute, all day, and your throttle waves them through.

The 429 you forgot to shape

Here is the part most APIs skip. When the limiter trips, Laravel returns a 429 with a body your client probably can't parse and headers your client probably ignores. Shape both.

RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)
        ->by($request->user()?->id ?: $request->ip())
        ->response(function (Request $request, array $headers) {
            return response()->json([
                'message' => 'Too many requests.',
                'retry_after' => $headers['Retry-After'] ?? null,
            ], 429, $headers);
        });
});
Enter fullscreen mode Exit fullscreen mode

The $headers array is the piece people never notice. The middleware hands your closure the computed rate-limit headers and you pass them straight into the response. On a throttled request Laravel sends Retry-After and X-RateLimit-Reset; on a successful one it sends X-RateLimit-Limit and X-RateLimit-Remaining. A well-behaved client reads Retry-After and backs off instead of retrying blind. If you never shaped the response, those headers may still ship, but nobody documented them to the client team, so nobody consumes them. Document the contract: what the 429 body looks like, and that Retry-After is in seconds.

What backs the counter

Every limit lives in the cache store. That single fact decides whether your limit means anything across more than one server.

If your cache driver is file or array, each web node keeps its own counter. Four nodes behind a load balancer turn "60 per minute" into "240 per minute," because the request lands on a different node each time and each node counts from zero. The fix is a shared store. Point the cache at Redis (or a shared database) so all nodes increment the same key.

Once Redis is your cache driver, tell Laravel to use its Redis-native limiter in bootstrap/app.php:

use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->throttleWithRedis();
})
Enter fullscreen mode Exit fullscreen mode

throttleWithRedis() swaps the default ThrottleRequests middleware for ThrottleRequestsWithRedis, which drives the counter with a Lua script that runs atomically inside Redis.

The distributed-counter caveat

Two problems survive even a shared store, and they are the reason throttleWithRedis() exists.

First, atomicity. The default cache limiter reads the current count, checks it against the max, then increments. Under real concurrency, two requests can read the same count before either writes, and both pass. At low traffic you never see it. At a burst of a hundred simultaneous requests, "60 per minute" quietly lets 65 through. The Redis Lua script increments and checks in one atomic step, so it does not overshoot.

Second, the window shape. The default limiter uses a fixed window. A client can send 60 requests at 11:00:59 and another 60 at 11:01:00 and pass both, because the counter resets on the minute boundary. That is 120 requests in two seconds while technically never breaking the limit. The Redis limiter uses a sliding window, which counts the trailing 60 seconds from now and smooths that boundary spike away.

If your limits are advisory (be nice to the API), the fixed-window default is fine. If they are protecting something that falls over under a coordinated burst, use the Redis limiter and know that even it is eventually consistent across replicas. A rate limiter is a speed bump, not a lock. Anything that needs a hard guarantee (no double charge, no duplicate order) needs a real idempotency key, not a counter.

Rate limiting outside HTTP

The RateLimiter facade works anywhere, not just on routes. Guard an outbound SMS or a third-party call that bills per request:

use Illuminate\Support\Facades\RateLimiter;

$key = "send-otp:{$user->id}";

$ok = RateLimiter::attempt($key, 3, function () use ($user) {
    $this->sms->sendOtp($user);
}, 300);

if (! $ok) {
    $seconds = RateLimiter::availableIn($key);
    abort(429, "Try again in {$seconds} seconds.");
}
Enter fullscreen mode Exit fullscreen mode

attempt() runs the closure only if the key is under its limit, counts the hit, and returns false when it isn't. availableIn() tells you when the window frees up. Three OTP sends per five minutes per user, enforced in a service, not a route. Same counter, same Redis, no middleware.


If this was useful

Rate limiting is an edge concern. It belongs in a limiter definition and a middleware, not braided through your controllers or, worse, your domain. The account that decides "three OTPs per five minutes" is a policy; the Redis counter that enforces it is an adapter; the thing being protected is a use case that should not know either one exists. Keeping those three apart is the whole subject of Decoupled PHP: the architectural layer your Laravel app reaches for once the framework defaults stop scaling with it.

Where does your rate-limit logic live today, and how many controllers would you have to touch to change one number?

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)