Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

Laravel Rate Limiting: Stop Abuse Before It Starts (2026)

Why rate limiting is the cheapest security you will ever add

Every public endpoint on your Laravel app is an invitation. Login forms invite credential stuffing, password-reset routes invite account enumeration, and any JSON API invites the scraper with a for-loop and a weekend to kill. Laravel rate limiting is the framework’s built-in answer: a few lines of configuration that say “this route gets N requests per minute — after that, a 429”. No packages, no custom middleware, no infrastructure to provision.

This guide covers the whole system as it works in Laravel 13.x, verified against the official docs and framework source on 22 October 2026: the quick throttle shorthand, named limiters, per-user and per-plan buckets, response-based counting for enumeration attacks, manual control with the RateLimiter facade, and the five mistakes that silently break your limits.

How Laravel rate limiting works in 60 seconds

Three pieces work together:

  1. The throttle middleware sits on your routes and counts requests against a cache-backed bucket.
  2. Named rate limiters, defined with RateLimiter::for(), describe the bucket: how many attempts, over what time window, and segmented by what key.
  3. When the bucket is empty, Laravel short-circuits the request with a 429 Too Many Requests response. Your controller never runs.

Every throttled response carries X-RateLimit-Limit and X-RateLimit-Remaining headers. When the limit is actually exceeded, Laravel also sends Retry-After (seconds until the bucket refills) and X-RateLimit-Reset — and X-RateLimit-Remaining reads 0. Your API clients and your frontend retry logic should read these headers instead of guessing.

The quick version: throttle:60,1

The fastest way to protect a route is the numeric shorthand. The middleware’s signature is handle($request, $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = ''), so the two numbers are attempts and decay window in minutes:

// 60 requests per minute, then 429
Route::middleware('throttle:60,1')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
    Route::get('/posts/{id}', [PostController::class, 'show']);
});

// Writes get a much tighter budget than reads
Route::post('/posts', [PostController::class, 'store'])
    ->middleware('throttle:10,1'); // 10 requests per minute

Write plain throttle with no arguments and you get the defaults: 60 attempts per minute. The shorthand is fine for quick protection, but the moment you need different rules for guests, users, and paying customers, you want named limiters.

Named rate limiters: the production way

Define your limiters in the boot() method of App\Providers\AppServiceProvider using the RateLimiter facade. The closure receives the incoming request, so limits can adapt to who’s asking:

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

public function boot(): void
{
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });
}

Then attach the limiter to routes by name:

Route::middleware(['throttle:api'])->group(function () {
    Route::get('/user', [UserController::class, 'show']);
    Route::apiResource('invoices', InvoiceController::class);
});

The Limit class gives you a small builder vocabulary — know all of it and you’ll rarely write manual counting code:

  • Limit::perSecond($n), Limit::perMinute($n), Limit::perHour($n), Limit::perDay($n) — attempts per window.
  • Limit::perMinutes($decayMinutes, $maxAttempts) — watch the argument order: decay window first, attempts second. It’s reversed compared to the others.
  • Limit::none() — no limit at all. Useful for VIP or internal callers.
  • ->by($key) — segments the bucket by an arbitrary value (user ID, IP, plan).
  • ->response($callback) — custom 429 response. The callback receives the request and the rate-limit headers so you can pass them through: response('Custom response...', 429, $headers).
  • ->after($callback) — only count the request toward the limit if the callback returns true for the response. More on this below.

Segment your limits: by user, IP, and plan

A single global bucket is almost never what you want. Authenticated users should get more room than anonymous traffic, and paying customers more than free ones. Build the limit dynamically from the request:

RateLimiter::for('uploads', function (Request $request) {
    return $request->user()
        ? Limit::perMinute(100)->by($request->user()->id)
        : Limit::perMinute(10)->by($request->ip());
});

// Or exempt your best customers entirely
RateLimiter::for('uploads', function (Request $request) {
    return $request->user()?->vipCustomer()
        ? Limit::none()
        : Limit::perHour(10);
});

You can also stack several limits on one limiter by returning an array — they’re evaluated in order:

RateLimiter::for('login', function (Request $request) {
    return [
        Limit::perMinute(500),
        Limit::perMinute(3)->by($request->input('email')),
    ];
});

One rule to memorise: when multiple limits in the same array share an identical by() value, they share the same bucket. Prefix your keys so each limit gets its own counter:

RateLimiter::for('uploads', function (Request $request) {
    return [
        Limit::perMinute(10)->by('minute:'.$request->user()->id),
        Limit::perDay(1000)->by('day:'.$request->user()->id),
    ];
});

Only count what matters: the after() trick

Here’s a scenario the basic throttle can’t express. An attacker is probing /users/{id} to enumerate accounts. Every 404 leaks information; every 200 confirms a target. You want to throttle the misses hard without punishing legitimate users who are browsing real pages — because their requests return 200s and shouldn’t burn the budget at all.

The after() method, which receives the response and returns whether it counts toward the limit, solves exactly this:

use Symfony\Component\HttpFoundation\Response;

RateLimiter::for('resource-not-found', function (Request $request) {
    return Limit::perMinute(10)
        ->by($request->user()?->id ?: $request->ip())
        ->after(function (Response $response) {
            // Only count 404 responses toward the rate limit
            // to prevent enumeration...
            return $response->status() === 404;
        });
});

Use the same pattern anywhere “failed attempts are the attack”: only count validation failures on a signup form, only count 401s on a token endpoint. Your real users keep a full budget; the prober runs out fast.

Login endpoints done right

Logins deserve the strictest limits on your app — they’re the front door for credential stuffing. The official docs’ login pattern stacks a generous global cap with a tight per-email cap:

RateLimiter::for('login', function (Request $request) {
    return [
        Limit::perMinute(500),
        Limit::perMinute(3)->by($request->input('email')),
    ];
});

Route::post('/login', [LoginController::class, 'store'])
    ->middleware('throttle:login');

Note the per-email segment: three attempts per minute per email address. An attacker rotating through a password list against one account gets throttled; your other users are untouched.

If you scaffolded auth with Laravel Breeze, this pattern is already wired into your LoginRequest: it ships ensureIsNotRateLimited() and throttleKey(), allows 5 attempts, keys the bucket on the lowercased email plus IP, hits the counter on every failed attempt, and clears it on success. If you rolled your own login controller, copy that last detail — never let successful logins burn the brute-force budget.

Manual control with the RateLimiter facade

Middleware covers routes, but sometimes the limit lives in weirder places: inside a controller action, around a queued job dispatch, or in code that never sees HTTP at all. The RateLimiter facade gives you the same buckets directly:

use Illuminate\Support\Facades\RateLimiter;

public function login(Request $request)
{
    $key = 'login:'.$request->ip().':'.$request->input('email');

    if (RateLimiter::tooManyAttempts($key, 5)) {
        $seconds = RateLimiter::availableIn($key);

        return response()->json([
            'message' => "Too many attempts. Try again in {$seconds} seconds.",
        ], 429);
    }

    if (! Auth::attempt($request->only('email', 'password'))) {
        RateLimiter::hit($key);

        return back()->withErrors(['email' => 'Invalid credentials.']);
    }

    RateLimiter::clear($key);

    return redirect()->intended('/dashboard');
}

The full facade vocabulary, all verified against the 13.x source:

  • RateLimiter::tooManyAttempts($key, $maxAttempts) — has the bucket run dry?
  • RateLimiter::hit($key, $decaySeconds = 60) — record one attempt (default window: 60 seconds).
  • RateLimiter::availableIn($key) — seconds until the next attempt is allowed.
  • RateLimiter::remaining($key, $maxAttempts) — attempts left in the window.
  • RateLimiter::clear($key) — empty the bucket (call this on success).
  • RateLimiter::resetAttempts($key) — reset the counter without touching the timer.

Running more than one server? Switch to Redis

Buckets live in your cache. If you run two app servers with a per-box cache, each box keeps its own buckets — and an attacker just has to spread requests across your fleet to dodge every limit. The fix is one line in bootstrap/app.php, which maps the throttle middleware to the Redis-backed implementation:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->throttleWithRedis();
})

This only works if Redis is actually your cache driver. If your production cache is the array driver, your buckets live in a single PHP process’s memory and limits effectively never bite — one of the most common “rate limiting doesn’t work” support threads, and always a config issue, never a framework bug.

Five mistakes that break your rate limits

1. Unprefixed by() keys share buckets

Two limits in one array with identical by() values count against the same bucket. Your per-minute and per-day limits quietly become one limit. Prefix every key ('minute:', 'day:') and the problem disappears.

2. The array cache driver in production

Rate limiting is only as real as the cache behind it. CACHE_DRIVER=array keeps buckets in process memory: under PHP-FPM every request is a fresh process, so every bucket starts empty. Use Redis (or at least file/database) for anything you actually want throttled.

3. IP-based limits behind a proxy

If your app sits behind Cloudflare, a load balancer, or any reverse proxy, $request->ip() only reflects the real client when you’ve configured trusted proxies. Without it, every visitor shares your proxy’s IP — one bucket for the whole internet, and one abusive user locks out everyone. Configure trusted proxies before you trust IP-segmented limits.

4. Counting successes when you meant failures

A flat throttle:5,1 on a login route punishes your real users for typos. For attack-shaped traffic, use after() so only failures (401s, 404s, validation errors) consume the budget. Legitimate traffic keeps a full allowance.

5. One limit for everyone

Guests, free users, and paying customers do not deserve the same budget. Segment with by() and Limit::none() for the tiers that pay for headroom. A scraper hitting your free tier should never be able to starve your customers.

Test it in 60 seconds

Don’t ship limits you haven’t watched trigger. A feature test that hammers a route and expects the 429 takes seconds to write:

it('throttles the login route after too many attempts', function () {
    $response = null;

    foreach (range(1, 6) as $i) {
        $response = $this->post('/login', [
            'email' => 'user@example.com',
            'password' => 'wrong-password',
        ]);
    }

    $response->assertStatus(429);
    $response->assertHeader('Retry-After');
});

Watch the X-RateLimit-Remaining header count down across the attempts, then confirm the bucket refills after the window. If your limits never trigger in the test, check mistake #2 before you blame the framework.

Further Reading & References

Leave a Comment