A 429 is the most underused signal in a Laravel app. Most teams set up throttling once, confirm it returns the right status code, and never look at it again. But the rate of 429s, broken down by route, is one of the earliest warnings you get for a whole class of problems — credential stuffing, a customer's integration stuck in a retry loop, a bot farming password resets. It's a security-and-reliability signal hiding inside a status code.
Setting up Laravel rate limits
app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('api', function ($request) {
$key = $request->header('X-Api-Key') ?? $request->ip();
return Limit::perMinute(100)->by($key);
});
RateLimiter::for('login', function ($request) {
return [
// 5 per minute per IP
Limit::perMinute(5)->by($request->ip()),
// 20 per minute per email (prevents targeted stuffing)
Limit::perMinute(20)->by($request->input('email')),
];
});
}routes/api.php
Route::middleware('throttle:api')->group(function () {
Route::get('/users', [UserController::class, 'index']);
Route::post('/orders', [OrderController::class, 'store']);
});
Route::post('/login', LoginController::class)
->middleware('throttle:login');Track 429s in your APM
429 responses show up in your request telemetry. The value is per-route grouping:
- /api/* 429 spike → a consumer's retry logic is broken, or an API key is compromised
- /login 429 spike → credential stuffing attack
- /password/reset 429 spike → someone farming password reset emails
- /api/webhooks/* 429 spike → your upstream is retrying because processing is slow
Log rate-limit hits with context
Custom middleware that logs 429s
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class LogRateLimitHits
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
if ($response->status() === 429) {
Log::warning('Rate limit hit', [
'route' => $request->route()?->uri(),
'method' => $request->method(),
'ip' => $request->ip(),
'api_key' => $request->header('X-Api-Key'),
'user_id' => auth()->id(),
'limit_after' => $response->headers->get('X-RateLimit-Remaining'),
]);
}
return $response;
}
}Distributed rate limiting with Redis
On a multi-server fleet, Laravel's default cache-store rate limiter doesn't sync across servers — each server has its own counter:
.env
CACHE_STORE=redis
REDIS_HOST=your-redis-host
REDIS_PORT=6379
# Optional: use a dedicated connection for rate limits
# so they're not evicted by general cache pressure:
# CACHE_RATE_LIMITER_CONNECTION=ratelimitWith Redis, increment+check operations are atomic and global. 100 req/min on a 3-server fleet means 100 total, not 300.
Alerting thresholds
| Route pattern | Alert at | Likely cause |
|---|---|---|
| /login | 10+ 429s/min | Credential stuffing |
| /api/* | 5x baseline sustained 10min | Runaway client retries |
| /password/* | 3+ 429s/min | Password-reset farming |
| /api/webhooks/* | any sustained | Upstream retry storm from slow processing |
The proxy trap
One subtle failure mode: if your app sits behind a load balancer or CDN and you key the limiter by $request->ip(), every request can look like it came from the proxy's IP. The limiter then throttles all your users as if they were one client, or — if the proxy rotates IPs — never throttles anyone. Laravel only reads the real client address from X-Forwarded-For when you've configured TrustProxies correctly. So if your rate limits behave nothing like you expect in production but work fine locally, check trusted proxies before you touch the limit values themselves.
THE EASY WAY
NightOwl surfaces 429s as part of request monitoring
Filter the requests dashboard by status=429 per route. Drill into specific rate-limited requests to see which IP, API key, or user hit the ceiling. Set alerts on 429 rate thresholds via any configured alert channel.
composer require nightowl/agent
php artisan nightowl:install