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());
});
}
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();
})
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());
});
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),
};
});
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);
});
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),
];
});
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');
});
Two behaviors are worth knowing before you rely on this:
-
Routes that use the same limiter name share a counter. Every route behind
throttle:uploadsdraws 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,1means 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 usingthrottle:60,1count 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);
});
});
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;
});
});
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()),
];
});
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);
}
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);
});
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:
-
arraykeeps 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. -
fileworks 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. -
databaseis shared across servers and works, but it adds database writes to every throttled request. In Laravel 12 the skeleton defaults to this store. -
redisis 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',
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();
})
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,
);
})
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');
}
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
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
- Laravel docs: Routing, Rate Limiting
- Laravel docs: Rate Limiting (the RateLimiter facade)
- Laravel docs: Configuring Trusted Proxies
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)