DEV Community

Cover image for Laravel Rate Limiting: Throttling Routes Correctly
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel Rate Limiting: Throttling Routes Correctly

Laravel rate limiting is easy to switch on and easy to get subtly wrong. A limit that every visitor shares because they all arrive from the load balancer's IP. A "60 per minute" that quietly becomes 240 because four servers each keep their own counter. A login limit that an attacker uses to lock your real users out. None of these show up in local development, and none of them throw an error. Part 1 covered routing basics, Part 2 model binding, Part 3 middleware, Part 4 route caching, and Part 5 API versioning. This part covers how throttling actually works, how to define limiters that fit a real application, and the production setups where it breaks.

How throttling works

Two pieces cooperate. A rate limiter is a named rule that says how many requests are allowed and who counts as "one client." The throttle middleware attaches that rule to routes. When a client goes over the limit, Laravel returns a 429 Too Many Requests response without running your controller.

Laravel also tells the client where it stands. Reading the middleware source, every response carries X-RateLimit-Limit and X-RateLimit-Remaining, and a throttled response adds Retry-After (seconds until the client may try again) and X-RateLimit-Reset (a Unix timestamp). Well-behaved API clients use these to back off, which is why you should keep them intact if you customize the 429 response later.

Counters are stored in your application's cache. That detail matters more than anything else in this article, and it comes back in the production sections.

Where limiters live in Laravel 11+

Laravel 10 and earlier defined a default api limiter in RouteServiceProvider. That provider is gone in Laravel 11, and so is the default limiter: a fresh app does not define an api limiter at all. Limiters are now defined in the boot() method of App\Providers\AppServiceProvider:

// app/Providers/AppServiceProvider.php
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());
    });
}
Enter fullscreen mode Exit fullscreen mode

This is the limiter from the Laravel docs: 60 requests per minute, counted per authenticated user ID, or per IP address for guests. The closure receives the current request, so the limit can depend on who is asking.

If you want the api middleware group to throttle every API route automatically, call throttleApi() in bootstrap/app.php. It adds throttle:api to the group, so the api limiter above has to exist. A reported Laravel 11 issue shows what happens otherwise: with no api limiter defined, the middleware tries to read an api attribute off the authenticated user and misbehaves. Define the limiter first, then enable the middleware:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->throttleApi();
})
Enter fullscreen mode Exit fullscreen mode

Defining limits that match your traffic

A Limit is built with perMinute(), perHour(), perDay() and friends, then refined with by() to choose what a "client" is. A few patterns cover most applications.

Different limits for guests and users

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

Tiered limits by plan

This assumes your User model has a plan attribute; adapt the field to whatever you actually store.

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

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

    return match ($user->plan) {
        'enterprise' => Limit::perMinute(1000)->by($user->id),
        'pro'        => Limit::perMinute(300)->by($user->id),
        default      => Limit::perMinute(60)->by($user->id),
    };
});
Enter fullscreen mode Exit fullscreen mode

Exempting some clients

Limit::none() removes the limit for a request, which is how the docs exempt VIP customers from an upload limit:

RateLimiter::for('uploads', function (Request $request) {
    return $request->user()->vipCustomer()
        ? Limit::none()
        : Limit::perHour(10);
});
Enter fullscreen mode Exit fullscreen mode

More than one limit at once

A limiter can return an array. Each limit is checked in order, which lets you combine a short burst limit with a longer daily cap. If two limits use the same by() value, prefix them so they don't share a counter:

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

Attaching a limiter to routes

Use the throttle middleware with the limiter's name. It works on a single route or a whole group, which pairs well with the versioned groups from Part 5:

// routes/api.php
Route::prefix('v1')->name('v1.')->middleware('throttle:api')->group(function () {
    Route::post('/audio', [AudioController::class, 'store'])->middleware('throttle:uploads');
});
Enter fullscreen mode Exit fullscreen mode

Two behaviors are worth knowing before you rely on this:

  • Routes that use the same limiter name share a counter. Every route behind throttle:uploads draws from the same budget for a given client. That is usually what you want for a group, and a surprise when you didn't intend it.
  • The older inline form shares counters too. throttle:60,1 means 60 attempts per 1 minute. From the middleware source, its signature is the user ID, or the route's domain plus the IP for guests. The URI is not part of it, so two routes using throttle:60,1 count against the same bucket unless you pass a third prefix argument. Named limiters are clearer, so prefer them.

If you stack two throttles on one route, as in the example above, both must pass. A request is rejected as soon as either limit is exceeded.

Returning your own 429 response

The default 429 is fine for a browser. A JSON API usually wants a predictable body. Use response() on the limit, and pass the headers through so clients still get Retry-After:

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' => 'Rate limit exceeded. Retry after the number of seconds in the Retry-After header.',
            ], 429, $headers);
        });
});
Enter fullscreen mode Exit fullscreen mode

The mistake to avoid is dropping $headers. Without them the client has no machine-readable way to know when to retry, and most will retry immediately.

Counting only some responses

Recent Laravel versions can decide whether a request counts after the response exists, using after(). It appears in the 12.x docs, so confirm your version has it. The docs use it to limit repeated 404s, which slows down someone enumerating IDs without penalizing normal traffic:

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) {
            return $response->status() === 404;
        });
});
Enter fullscreen mode Exit fullscreen mode

Protecting login and OTP endpoints

Authentication endpoints are where limits matter most, and where the choice of key matters most. The Laravel docs show a login limiter segmented by the submitted email address. That stops password guessing against one account, but it also lets anyone lock a known victim out by hammering that email address. Combine the email with the IP so an attacker can only exhaust their own budget, and keep a looser IP-only limit to catch spraying across many accounts:

use Illuminate\Support\Str;

RateLimiter::for('login', function (Request $request) {
    $email = Str::lower((string) $request->input('email'));

    return [
        Limit::perMinute(5)->by('email-ip:'.$email.'|'.$request->ip()),
        Limit::perMinute(30)->by('ip:'.$request->ip()),
    ];
});
Enter fullscreen mode Exit fullscreen mode

Lowercasing the email matters: otherwise User@example.com and user@example.com get separate buckets and the limit is trivial to bypass. Note that this limiter only sees what's in the request. On a route without an auth middleware, $request->user() is null, so limiting "per user" silently falls back to the IP.

For limits that aren't tied to a route, such as sending an SMS one-time code, call the RateLimiter facade directly. attempt() runs your callback only if the client is still under the limit:

use Illuminate\Support\Facades\RateLimiter;

$sent = RateLimiter::attempt(
    'otp:'.$user->id,   // key
    3,                  // max attempts
    function () use ($user) {
        $this->sendOtp($user);
    },
    600,                // decay: 10 minutes, in seconds
);

if (! $sent) {
    $seconds = RateLimiter::availableIn('otp:'.$user->id);

    return response()->json([
        'message' => "Too many codes requested. Try again in {$seconds} seconds.",
    ], 429);
}
Enter fullscreen mode Exit fullscreen mode

Limiting by API key

For a partner or public API, key the limit to the client's identity rather than the IP, since many clients can share one address and one client can use many. Look the key up yourself, and give unknown keys a tight IP-based limit so invalid keys can't each create a fresh generous bucket. ApiClient::findByToken() below is a placeholder for your own lookup, and a lookup that hits the database on every request should be cached:

RateLimiter::for('partner-api', function (Request $request) {
    $client = ApiClient::findByToken((string) $request->bearerToken());

    if (! $client) {
        return Limit::perMinute(10)->by('anon:'.$request->ip());
    }

    return Limit::perMinute($client->requests_per_minute)->by('client:'.$client->id);
});
Enter fullscreen mode Exit fullscreen mode

Production: the cache store decides whether limits work

Rate limit counters live in the cache, so the cache configuration is part of your rate limiting configuration:

  • array keeps counters in memory for the current process only. Nothing carries over between requests, so limits never trigger in a normal web setup. It is the right store for tests and the wrong one for production.
  • file works on one server. With several servers behind a load balancer, each keeps its own counter, so a limit of 60 per minute becomes roughly 60 per minute per server.
  • database is shared across servers and works, but it adds database writes to every throttled request. In Laravel 12 the skeleton defaults to this store.
  • redis is shared and fast, and is the usual production choice.

You can point only the limiter at Redis without moving your whole cache, using the limiter key in config/cache.php:

// config/cache.php
'limiter' => 'redis',
Enter fullscreen mode Exit fullscreen mode

If Redis is your cache driver, you can also swap the middleware for the Redis-specific implementation, ThrottleRequestsWithRedis, by calling throttleWithRedis():

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->throttleWithRedis();
})
Enter fullscreen mode Exit fullscreen mode

Production: behind a load balancer, every user has the same IP

Guest limits are keyed on $request->ip(). If your app sits behind a load balancer or reverse proxy and Laravel isn't told to trust it, ip() returns the proxy's address for every request. Every guest then shares one bucket, and the first busy minute gives all of them a 429 at the same moment, which looks like an outage rather than a rate limit.

Tell Laravel which proxies to trust in bootstrap/app.php, so it reads the client address from the forwarded headers:

use Illuminate\Http\Request;

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(
        at: ['10.0.0.0/8'],   // your load balancer's address or range
        headers: Request::HEADER_X_FORWARDED_FOR |
            Request::HEADER_X_FORWARDED_HOST |
            Request::HEADER_X_FORWARDED_PORT |
            Request::HEADER_X_FORWARDED_PROTO,
    );
})
Enter fullscreen mode Exit fullscreen mode

The docs also allow at: '*' for cloud load balancers whose addresses change. That is a security trade-off for rate limiting specifically: if clients can reach your servers directly, anyone can send a forged X-Forwarded-For header and appear as a different IP on every request, which defeats every IP-based limit. Trust specific addresses where you can, and use '*' only when the app is reachable exclusively through the load balancer.

What Laravel throttling does not do

The limiter runs after PHP has booted and the request has reached your application, and each check costs a cache read and write. That protects your application logic and your database from abusive clients, but it is not defense against volumetric attacks, since the traffic still reaches your servers. Put a limit at the edge as well, in nginx (limit_req), your CDN, or a WAF, and treat the Laravel limiter as the finer-grained second layer that knows about users, plans, and endpoints.

Testing and verifying limits

Write a feature test that exhausts the limit and checks the 429. Laravel's default phpunit.xml uses the array cache store, which is fine inside a single test run, but check yours:

public function test_login_is_rate_limited(): void
{
    for ($i = 0; $i < 5; $i++) {
        $this->postJson('/api/login', ['email' => 'a@example.com', 'password' => 'wrong']);
    }

    $this->postJson('/api/login', ['email' => 'a@example.com', 'password' => 'wrong'])
        ->assertStatus(429)
        ->assertHeader('Retry-After');
}
Enter fullscreen mode Exit fullscreen mode

Then check the real environment, where cache and proxy settings differ from your tests. This loop prints the status and rate limit headers for eight consecutive requests:

for i in $(seq 1 8); do
  curl -s -o /dev/null -D - https://example.com/api/v1/ping \
    | grep -iE '^HTTP/|x-ratelimit|retry-after'
  echo ---
done
Enter fullscreen mode Exit fullscreen mode

Run it from two different machines. If the second machine is throttled as soon as the first one is, you have the shared-proxy-IP problem. If the limit takes roughly N times as many requests to trigger as you configured, with N servers, you have per-server counters. php artisan route:list -v will also show whether the throttle middleware is actually attached to the route you think it is.

Common problems

Symptom Likely cause Fix
All guests get 429 at once Behind a proxy; every request has the proxy's IP Configure trustProxies with your proxy addresses
Effective limit is N times the configured one file cache with N servers, each counting alone Use Redis or another shared store
Limit never triggers array cache in a real environment, or throttle middleware not attached Use a persistent shared store; check route:list -v
Error on every API request after enabling throttleApi() No api limiter defined (Laravel 11+) Define it with RateLimiter::for('api', ...)
Real users locked out during an attack Login limit keyed on email only Key on email plus IP, and add an IP-only limit
Authenticated users limited per IP No auth middleware on the route, so $request->user() is null Run authentication on the route before relying on the user
Two endpoints drain the same budget Same limiter name or inline throttle:60,1 Use separate named limiters
Clients hammer the API after a 429 Custom response dropped the headers Pass $headers into your 429 response

Before you ship it

Define every limiter by name and check it appears on the routes you meant with php artisan route:list -v. Point the limiter at Redis or another shared store. Set trusted proxies before you launch, not after the first incident. Key authentication limits on more than the email. Keep Retry-After in every custom 429, and document it for client developers. Then run the curl loop from two machines on the real environment, because that is the one test your local setup can't give you.

References

Related reading: this series starts with Part 1: Laravel Routing Basics. The previous parts are Part 3: Middleware, Part 4: Route Caching and Part 5: API Versioning.


Originally published on DEV Talk.

Top comments (0)