How to Fix 429 Too Many Requests in Laravel: Throttle and Rate Limiting Done Right
You push to production, traffic grows, and suddenly your logs fill with 429 Too Many Requests in Laravel. Legitimate users see "Too Many Attempts", your mobile app starts failing, and your first instinct is to just turn the throttle off. That fixes it for a day. Then the bots find the endpoint, and you are debugging a very different problem at 3 a.m.
This guide walks through every real cause of the 429 Too Many Requests error in Laravel, how to diagnose which one is biting you, and working code that fixes it without leaving your app unprotected. Everything below is tested against Laravel 11 and 12, where the api throttle middleware and named rate limiters work the same way.
What the 429 Error Actually Means in Laravel
The 429 Too Many Requests response comes from Laravel's throttle middleware. Every request that passes through it gets counted against a named limit — for example, 60 requests per minute. When the count for a key exceeds the limit, Laravel throws a ThrottleRequestsException and responds with 429.
The important part is the key. Laravel counts attempts per key, and that key is built from the limiter you configured: usually the user's ID for logged-in users, or the IP address for guests. Most 429 Too Many Requests problems in Laravel are not about traffic being too high — they are about the key or the limiter being wrong.
Every 429 response also carries a Retry-After header telling the client how many seconds to wait. If your custom error handling drops that header, API clients have no way to back off gracefully, and they hammer the endpoint again the moment they retry.
Cause 1 — The Default API Throttle Is Too Strict
Fresh Laravel apps ship with the api limiter set to 60 requests per minute per user or IP. That is fine for a toy project. A real dashboard with auto-refresh, polling, or a chatty mobile client can burn through 60 requests in seconds.
Diagnose it first. Before changing anything, confirm the throttle middleware is actually attached to the route you think it is:
php artisan route:list -v | grep throttle
If the 429 responses carry an X-RateLimit-Remaining header that bottoms out at exactly 60, you are hitting the default api limiter. The fix is to define your own limiter with a sane budget instead of accepting the default. In Laravel 11 and 12, you define named limiters in your application's bootstrap:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
// app/Providers/AppServiceProvider.php (or bootstrap/app.php)
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(300)->by($request->user()?->id ?: $request->ip());
});
This raises the budget to 300 per minute and, crucially, keys it on the user ID when someone is logged in. IP-keyed limits are dangerous for authenticated users: one office behind a NAT gateway shares a single IP, and five employees can exhaust a guest budget before any one of them looks abusive.
Cause 2 — Two Endpoints Sharing One Limiter
Inline limits like throttle:60,1 are easy to write and easy to regret. When you apply throttle:api to two different endpoints, both draw from the same budget. A webhook receiver and your search endpoint should never share a limit — one bursty integration drains the budget and the other starts 429-ing real users.
The fix is one named limiter per behavior:
RateLimiter::for('webhooks', fn (Request $request) => Limit::perMinute(600)->by($request->ip()));
RateLimiter::for('search', fn (Request $request) => Limit::perMinute(120)->by($request->user()?->id ?: $request->ip()));
Route::post('/webhooks/stripe', [WebhookController::class, 'store'])
->middleware('throttle:webhooks');
Route::get('/search', [SearchController::class, 'index'])
->middleware('throttle:search');This is the same isolation principle behind good API design: if you are building endpoints that third parties will hit at unpredictable rates, it pays to design them as a proper custom REST API from the start
Cause 3 — Behind a Proxy, Every Request Has the Same IP
This is the most common 429 Too Many Requests cause in production Laravel apps and the least obvious. When your app sits behind a load balancer, Cloudflare, or a reverse proxy, every request arrives from the proxy's IP. If your limiter keys on IP, all of your traffic — every user, every bot — counts as one person.
The symptom: 429s spike immediately after a deploy or a traffic surge, the X-Forwarded-For header looks fine, but the throttled IP in your logs is always your proxy. The fix is to tell Laravel which proxies to trust. In Laravel 11+, add this to your bootstrap:
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: ['*']);
})In production, never use at: ['*'] blindly — list your actual proxy addresses or CIDR ranges so clients cannot spoof X-Forwarded-For. Trusting the right proxies restores the real client IP, and your per-IP limits start working the way you intended.
Cause 4 — File Cache Across Multiple Servers
Laravel's throttle middleware stores its counters in the cache. If CACHE_DRIVER is set to file and you run two or more app servers, each server counts independently. Your effective limit is the configured limit multiplied by the number of servers, and worse, counters are never shared, so behavior looks random: a request fails on server A and succeeds on server B.
The fix: use Redis (or Memcached) as the cache driver in any environment with more than one server:
With Redis the counters are atomic, fast, and shared across all servers. This one config change turns your rate limit from an estimate into an actual guarantee.
Cause 5 — The throttleApi Error on Every Request
In Laravel 11+, if you enable the api throttle with a named limiter that you never defined — for example, calling $middleware->throttleApi() and then attaching throttle:api to routes without a RateLimiter::for('api') definition — every API request 429s or errors from the first call. The symptom is unmistakable: a brand-new user makes one request and gets 429.
The fix is to always define the limiter you reference. If you do not need per-user nuance, the simplest correct definition is:
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60));No reference to a limiter anywhere means no mystery errors. Run php artisan route:list -v after wiring everything up and read the middleware column line by line.
Cause 6 — Login and Auth Routes Keyed Wrong
Auth endpoints deserve their own strict limit, keyed on more than just the email. A common setup limits logins per email, but then one attacker brute-forcing a single account burns through legitimate users' budgets, or worse, the lockout becomes a tool for denying real users access.
A robust login limiter keys on the email plus the IP, and the whole flow clears the counter on success so a legitimate user starts fresh:
public function login(Request $request)
{
$key = 'login:' . $request->ip() . '|' . strtolower($request->input('email'));
if (RateLimiter::tooManyAttempts($key, 5)) {
return response()->json([
'message' => 'Too many attempts. Please try again later.',
'retry_after' => RateLimiter::availableIn($key),
], 429);
}
if (! Auth::attempt($request->only('email', 'password'))) {
RateLimiter::hit($key, 60);
return response()->json(['message' => 'Invalid credentials.'], 401);
}
RateLimiter::clear($key);
$request->session()->regenerate();
return response()->json(['message' => 'Authenticated.']);
}
Four methods do all the work here: RateLimiter::hit records an attempt, tooManyAttempts checks the count, clear resets it after a successful login, and availableIn returns the seconds until the limit resets so you can feed a honest Retry-After to the client. Never reveal how many attempts were made or whether the account exists — a generic message plus retry timing keeps brute-force attackers from enumerating users. Apply the same discipline to password resets, registration, OTP resends, and email verification: these are the endpoints bots actually attack.
Handling 429 Gracefully Instead of Swallowing It
Whatever you do, keep the Retry-After header in your 429 responses. If you customize the exception rendering, pass the headers through:
use Illuminate\Http\Exceptions\ThrottleRequestsException;
public function render($request, Throwable $exception)
{
if ($exception instanceof ThrottleRequestsException) {
Log::warning('Rate limit exceeded', [
'ip' => $request->ip(),
'url' => $request->url(),
]);
return response()->json([
'message' => 'Too many requests. Please slow down and try again.',
], 429, $exception->getHeaders());
}
return parent::render($request, $exception);
}
Logging every 429 with the IP and URL gives you the evidence trail you need. When a legitimate integration starts failing, you will see the pattern in minutes instead of guessing. And on the client side, respect the header: exponential backoff on 429 and 5xx responses is the difference between a retry that works and a retry storm.
Testing Your Limits Before You Ship
Do not trust a limiter you have never triggered. The cheapest verification is a curl loop from two machines against your real environment — your local setup cannot reproduce proxy IPs, shared caches, or real client behavior:
for i in $(seq 1 70); do
curl -s -o /dev/null -w "%{http_code} %{header_json} \n" \
-H "Authorization: Bearer $TOKEN" \
https://webixyhub.com/api/search?q=demo
done | sort | uniq -c
You are looking for the first 60 responses to be 200 and the 61st onward to be 429 with a Retry-After header present. If 429s arrive early, your key is wrong (check trusted proxies). If they never arrive, your limiter is not attached or your cache driver is array in a real environment.
The 429 Checklist
One named limiter per behavior, never shared across unrelated endpoints. Limits keyed on user ID for authenticated traffic, IP only for guests. Trusted proxies configured with real addresses before launch, not after the first incident. Redis or another shared cache driver in production. Auth endpoints limited on email plus IP, with the counter cleared on successful login. Retry-After preserved in every custom 429 response. And a curl test against the real environment before you call it done.