The first time I watched a Laravel API survive a bot wave, the thing that did the work was twenty lines in bootstrap/app.php. No WAF, no Cloudflare rule, just a RateLimiter::for('api', ...) definition and the default throttle:api middleware doing exactly what it said on the tin. The database kept breathing because the framework refused to let the bots get to it.

Rate limiting in Laravel has the unusual property of being both very easy and very easy to do wrong. The defaults are fine for a demo. They are not fine for an API anyone actually uses. The differences between the two are small — they're about the key you bucket on, the headers you send back, and the fact that the limiter has to be on Redis the moment you have more than one web server.

What throttle:60,1 Actually Does

The shortest possible rate limiter is the one Laravel ships with: middleware on a route, like throttle:60,1. That means "60 requests per 1 minute, keyed by the authenticated user ID — or the IP if there's no user." Behind the scenes it increments a counter in your cache store, compares against the limit, and either lets the request through or returns a 429 Too Many Requests.

The framework also sets three response headers automatically:

Text
X-RateLimit-Limit:     60
X-RateLimit-Remaining: 47
Retry-After:           38

Clients that respect these (most SDKs do, most ad-hoc curl scripts don't) can self-throttle. The Retry-After value is in seconds when a request is rejected, and is the right thing to back off on.

That's the whole engine. Everything else is configuration.

The Modern Way: Named Limiters In bootstrap/app.php

In Laravel 11 the bootstrap moved from RouteServiceProvider to bootstrap/app.php. The recommended pattern is to define named limiters there using closures, then reference them from middleware:

PHP
// bootstrap/app.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        api: __DIR__.'/../routes/api.php',
        // ...
    )
    ->withMiddleware(function (Middleware $middleware) {
        // global middleware tweaks
    })
    ->withProviders()
    ->booted(function () {
        RateLimiter::for('api', function (Request $request) {
            return Limit::perMinute(60)->by(
                optional($request->user())->id ?: $request->ip()
            );
        });

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

        RateLimiter::for('uploads', function (Request $request) {
            return $request->user()?->isPro()
                ? Limit::perMinute(120)->by($request->user()->id)
                : Limit::perMinute(20)->by($request->user()->id);
        });
    })
    ->create();

A named limiter can return one Limit, an array of them (each one evaluated independently), or Limit::none() to opt-out. The by() argument is the bucket key — the thing the counter is namespaced to. Two limits in the same bucket are checked in AND, so the login limiter above lets you make 5 attempts on a given email and 20 from a given IP, whichever runs out first.

You then attach them by name from your routes:

PHP
// routes/api.php
Route::middleware(['auth:sanctum', 'throttle:api'])
    ->prefix('v1')
    ->group(function () {
        Route::apiResource('orders', OrderController::class);

        Route::post('uploads', UploadController::class)
            ->middleware('throttle:uploads');
    });

// routes/web.php
Route::post('login', LoginController::class)->middleware('throttle:login');

The Key Decision Is The Bucket Key

The single most common rate-limiter bug I see is bucketing on the wrong thing.

If you bucket on $request->ip() for an authenticated API, every user sitting behind the same corporate NAT shares one bucket — one noisy user starves the rest of the office. If you bucket on $request->user()->id for an anonymous endpoint, you get a null key and the whole limiter quietly collapses into a single shared bucket for every unauthenticated client.

The pattern that works:

PHP
->by(optional($request->user())?->id ?: $request->ip())

For the user-and-IP-fallback case. If you want to be even stricter, key on a token ID for token-authenticated requests so a leaked token gets its own ceiling:

PHP
->by(
    $request->user()?->currentAccessToken()?->id
    ?? $request->user()?->id
    ?? $request->ip()
)

For login endpoints, the right key is the email being attempted (so an attacker can't spray a million emails from one IP) plus a separate IP-based limit (so an attacker can't try one email a million times from rotating accounts). The double-limit pattern in the example above handles both.

Diagram of a token-bucket rate limiter: requests arriving at a Laravel app, the throttle middleware checking a Redis-backed counter keyed by user ID or IP, allowed requests passing through to the controller, and a 429 response with X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers being returned when the bucket is empty. A side panel shows different tiers — anonymous, authenticated, pro user — each with their own per-minute ceiling.
Same middleware, different keys — the bucket key is the design decision.

Per-Tier Limits For Real Products

Most real APIs don't have one limit. They have a tier system. Anonymous traffic gets a low ceiling, free accounts get a moderate one, paying customers get something they will not notice. The named-limiter pattern handles this cleanly:

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

    if (! $user) {
        return Limit::perMinute(30)->by($request->ip());
    }

    return match ($user->plan) {
        'enterprise' => Limit::none(),
        'pro'        => Limit::perMinute(600)->by($user->id),
        default      => Limit::perMinute(120)->by($user->id),
    };
});

Limit::none() is worth knowing about — it skips the limiter entirely for that request. Useful for enterprise contracts where you've negotiated unlimited access, or for internal service-to-service calls authenticated by a service account.

You can also override the response when the limit is hit. By default Laravel returns the JSON {"message": "Too Many Attempts."} with a 429. If you want a problem+json envelope or a Retry-After formatted as an HTTP date instead of seconds, attach a response callback:

PHP
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([
                'type'   => 'https://api.example.com/problems/rate-limited',
                'title'  => 'Too many requests',
                'status' => 429,
                'detail' => 'Slow down. Try again in '.$headers['Retry-After'].' seconds.',
            ], 429, $headers + ['Content-Type' => 'application/problem+json']);
        });
});

Redis Is Not Optional Once You Have More Than One Worker

The default cache.default driver in a fresh Laravel project is often file or database. For a rate limiter, that is a bug waiting to happen. The moment you scale to two web servers, each one keeps its own counter, and your effective limit doubles. Worse, if you're using array (which the default queue worker does in some setups), the counter resets on every request.

The fix is to point the cache and the rate-limiter store at Redis. Redis is fast, atomic across processes, and the right answer for distributed counters:

ENV
# .env
CACHE_STORE=redis
REDIS_CLIENT=phpredis
PHP
// config/cache.php — make sure 'redis' is the default
'default' => env('CACHE_STORE', 'redis'),

Once that's in place, every web node, every queue worker, every Octane process shares one bucket per key. That is the only configuration where the limit you set is the limit you actually enforce.

The Bigger Picture: Limits Below And Above Your App

A Laravel throttle middleware protects your app from your own users. It does not protect your app from a coordinated DDoS — that traffic never reaches PHP if your edge is doing its job. Real production rate limiting usually has three layers:

  1. Edge — Cloudflare, Fastly, AWS WAF. Drops obvious bad traffic before it hits your origin. The numbers here are coarse: "100 requests per second per IP, full stop."
  2. Reverse proxy — Nginx with limit_req_zone, or a dedicated API gateway like Kong, Traefik. Catches abuse that slipped past the edge.
  3. Application — Laravel's RateLimiter. The fine-grained, per-user, per-tier, per-endpoint logic the upstream layers can't express because they don't know who the user is.

If you only have one layer, the application layer is the most valuable — it's the only one that knows the user is on a Pro plan, that the endpoint is /exports and exports are expensive, that this token has the orders:write scope and so should be allowed more writes than the read-only one.

Mistakes That Show Up In Code Review

A few patterns I push back on every time:

  1. Throttling write endpoints with the same limit as reads. GET /orders is cheap; POST /orders triggers payment, inventory, email. They deserve different limits.
  2. No limit on auth endpoints. Login, password reset, and signup are the highest-value abuse targets. They should be the most aggressively throttled routes in the whole app, with the dual email-plus-IP key.
  3. Returning a generic 500 instead of 429. If the limiter is doing its job, the response is a 429 with a Retry-After header. Generic errors break client retry logic and hide the real cause from your logs.
  4. Forgetting to test it. A rate limiter that's never been exercised in CI is a rate limiter that's silently broken because someone changed the cache driver.
PHP
it('rate limits unauthenticated callers per IP', function () {
    Cache::flush(); // clear the limiter

    for ($i = 0; $i < 30; $i++) {
        $this->getJson('/api/v1/public/health')->assertOk();
    }

    $this->getJson('/api/v1/public/health')
        ->assertStatus(429)
        ->assertHeader('Retry-After');
});

A test like this catches the day someone "tidies up" bootstrap/app.php and accidentally removes the for('api', ...) definition.

Five production rate-limiting rules arranged as cards around a central Laravel application icon: key on the right thing, put the limiter on Redis, tier the limits, give auth endpoints their own bucket, and pin the whole thing with a test in CI.
Five rules that turn a throttle middleware into a rate limiter you can trust at 2 AM.

A One-Sentence Mental Model

Rate limiting is a single named closure in bootstrap/app.php, keyed on the right thing (user.id ?? ip), backed by Redis the moment you have more than one worker, returning structured 429s with Retry-After — and tiered so paying customers feel nothing while abusive traffic feels everything.