Rate Limiting and API Throttling in Laravel — Protecting Your App from Abuse
Without rate limiting, your application is like a restaurant with no seating limit — everyone can walk in, as many as they want, and your server will collapse when a flood of spam arrives.
Rate limiting helps:
- Protect your server from DDoS and brute-force attacks
- Ensure fairness among users
- Control costs for APIs (especially when integrating with paid services like OpenAI, Stripe)
- Comply with SaaS SLAs per tier/plan
Basic Rate Limiting in Laravel
Laravel ships with the throttle middleware:
// routes/api.php
Route::middleware('throttle:60,1')->group(function () {
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);
});
This means: 60 requests per 1 minute per user (identified by IP or authenticated user ID).
When the limit is exceeded, Laravel automatically returns:
HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
Header explanations:
X-RateLimit-Limit: Total requests allowed in the windowX-RateLimit-Remaining: Requests remainingRetry-After: Seconds until the limit resets
Named Rate Limiters (Laravel 8+)
Instead of hardcoding numbers in routes, define named limiters:
// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Http\Request;
public function boot(): void
{
// General API limiter
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip());
});
// Login limiter (anti brute-force)
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)
->by($request->input('email') . '|' . $request->ip())
->response(function (Request $request, array $headers) {
return response()->json([
'message' => 'Too many attempts. Please wait.',
'retry_after' => $headers['Retry-After'],
], 429, $headers);
});
});
// Upload limiter (stricter)
RateLimiter::for('uploads', function (Request $request) {
return Limit::perMinute(10)->by($request->user()->id);
});
// Webhook limiter (more generous since it comes from external services)
RateLimiter::for('webhooks', function (Request $request) {
return Limit::perMinute(500)->by($request->ip());
});
}
Using them in routes:
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:login');
Route::middleware('throttle:api')->group(function () {
Route::apiResource('posts', PostController::class);
});
Route::post('/upload', [UploadController::class, 'store'])
->middleware(['auth', 'throttle:uploads']);
Explanation of ->by(): Defines the "key" for grouping requests. For example:
->by($request->ip()): Limit by IP. All users sharing an IP share the limit.->by($request->user()->id): Limit by user. Each user gets their own limit.->by($request->input('email') . '|' . $request->ip()): Combines email and IP for login. Prevents brute-force from multiple IPs targeting the same account.
Plan-Based Rate Limiting (SaaS)
This is the most important pattern for SaaS. Each tier (free, pro, enterprise) gets a different limit:
// app/Providers/AppServiceProvider.php
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
if (!$user) {
// Guest: 30 requests/minute
return Limit::perMinute(30)->by($request->ip());
}
// Get limit from user's plan
return match ($user->plan) {
'free' => Limit::perMinute(60)->by($user->id),
'pro' => Limit::perMinute(300)->by($user->id),
'enterprise' => Limit::perMinute(1000)->by($user->id),
default => Limit::perMinute(60)->by($user->id),
};
});
Advanced: Rate Limit by Both Day and Minute
Sometimes you want to limit both burst (short-term) and daily quota (long-term):
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
if (!$user) {
return Limit::perMinute(30)->by($request->ip());
}
$limits = match ($user->plan) {
'free' => [
Limit::perMinute(60)->by($user->id),
Limit::perDay(1000)->by($user->id),
],
'pro' => [
Limit::perMinute(300)->by($user->id),
Limit::perDay(10000)->by($user->id),
],
'enterprise' => [
Limit::perMinute(1000)->by($user->id),
Limit::perDay(100000)->by($user->id),
],
default => [
Limit::perMinute(60)->by($user->id),
],
};
return $limits;
});
Explanation: When returning an array, Laravel checks all limits. A request is rejected if any limit is exceeded. For example, a "free" user can send 60 req/minute, but max 1000 req/day. This prevents a user from sending 60 req/minute continuously all day (= 86,400 req).
Custom Rate Limiter: Sliding Window
Laravel defaults to a Fixed Window algorithm — it resets the counter each minute at a fixed point. The downside is that a user can send exactly 60 requests at the end of one window, then 60 more at the start of the next = 120 requests in 2 seconds.
Sliding Window solves this:
// app/Services/SlidingWindowRateLimiter.php
namespace App\Services;
use Illuminate\Support\Facades\Redis;
class SlidingWindowRateLimiter
{
/**
* Check and record a request.
*
* @param string $key Identifier (user_id, IP, ...)
* @param int $maxAttempts Maximum requests allowed
* @param int $windowSeconds Window size (seconds)
* @return array{allowed: bool, remaining: int, retryAfter: int}
*/
public function attempt(string $key, int $maxAttempts, int $windowSeconds): array
{
$now = microtime(true);
$windowStart = $now - $windowSeconds;
$member = $now . ':' . uniqid('', true);
$result = Redis::pipeline(function ($pipe) use ($key, $windowStart, $now, $member, $windowSeconds) {
// Remove entries older than the window
$pipe->zremrangebyscore($key, '-inf', $windowStart);
// Add current request
$pipe->zadd($key, $now, $member);
// Count entries in window
$pipe->zcard($key);
// Set TTL for auto-cleanup
$pipe->expire($key, $windowSeconds);
});
$currentCount = $result[2];
$allowed = $currentCount <= $maxAttempts;
if (!$allowed) {
// Find the oldest entry, calculate when it expires
$oldestEntries = Redis::zrange($key, 0, 0, 'WITHSCORES');
$oldestScore = !empty($oldestEntries) ? reset($oldestEntries) : $now;
$retryAfter = (int) ceil(($oldestScore + $windowSeconds) - $now);
}
return [
'allowed' => $allowed,
'remaining' => max(0, $maxAttempts - $currentCount),
'retryAfter' => $allowed ? 0 : ($retryAfter ?? $windowSeconds),
'limit' => $maxAttempts,
];
}
}
Algorithm explanation:
- Use a Redis Sorted Set (ZSET) with score = timestamp
- Each request adds a member to the set
- Remove all members older than
now - windowSeconds - Count remaining members = requests in the sliding window
- If count > maxAttempts → reject
Comparison:
Fixed Window (60 req/min):
|-------- Minute 1 --------|-------- Minute 2 --------|
[60 req][60 req]
↑ 120 requests in 2 seconds! ↑
Sliding Window (60 req/min):
|<--- always looks back 60 seconds --->|
Never exceeds 60 in any 60-second period
Middleware Using Sliding Window
// app/Http/Middleware/SlidingWindowThrottle.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Services\SlidingWindowRateLimiter;
class SlidingWindowThrottle
{
public function __construct(
private SlidingWindowRateLimiter $limiter,
) {}
public function handle(Request $request, Closure $next, int $maxAttempts = 60, int $windowSeconds = 60)
{
$key = 'rate_limit:' . ($request->user()?->id ?: $request->ip());
$result = $this->limiter->attempt($key, $maxAttempts, $windowSeconds);
if (!$result['allowed']) {
return response()->json([
'message' => 'Too many requests. Please slow down.',
'retry_after' => $result['retryAfter'],
], 429, [
'X-RateLimit-Limit' => $result['limit'],
'X-RateLimit-Remaining' => 0,
'Retry-After' => $result['retryAfter'],
]);
}
$response = $next($request);
return $response->withHeaders([
'X-RateLimit-Limit' => $result['limit'],
'X-RateLimit-Remaining' => $result['remaining'],
]);
}
}
Register the middleware:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'throttle.sliding' => \App\Http\Middleware\SlidingWindowThrottle::class,
]);
})
Usage:
// 100 requests per 60 seconds (sliding window)
Route::middleware('throttle.sliding:100,60')->group(function () {
Route::get('/api/search', [SearchController::class, 'index']);
});
Rate Limiting for Specific Actions
Not every endpoint needs the same limit. Some actions need stricter protection:
// app/Providers/AppServiceProvider.php
// Sending emails (prevent spam)
RateLimiter::for('send-email', function (Request $request) {
return Limit::perHour(10)->by($request->user()->id)
->response(function () {
return response()->json([
'message' => 'Too many emails sent. Try again in 1 hour.',
], 429);
});
});
// Password reset (anti brute-force)
RateLimiter::for('password-reset', function (Request $request) {
return [
Limit::perMinute(3)->by($request->ip()),
Limit::perHour(10)->by($request->input('email')),
];
});
// Data export (resource-intensive)
RateLimiter::for('export', function (Request $request) {
return Limit::perHour(5)->by($request->user()->id)
->response(function () {
return response()->json([
'message' => 'You can only export 5 times per hour.',
], 429);
});
});
// API key-based limiting
RateLimiter::for('api-key', function (Request $request) {
$apiKey = $request->header('X-API-Key');
if (!$apiKey) {
return Limit::perMinute(10)->by($request->ip());
}
// Get limit from database based on API key
$keyRecord = ApiKey::where('key', hash('sha256', $apiKey))->first();
if (!$keyRecord) {
return Limit::none(); // Will be rejected by auth middleware
}
return Limit::perMinute($keyRecord->rate_limit)
->by('api_key:' . $keyRecord->id);
});
Client Retry Logic
When an API returns 429, the client should handle it intelligently instead of spamming more requests:
// Example: Laravel HTTP Client calling an external API
use Illuminate\Support\Facades\Http;
$response = Http::retry(3, function (int $attempt, \Exception $exception) {
// Exponential backoff: 1s, 2s, 4s
$baseDelay = 1000;
// If Retry-After header is present, use it
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
$retryAfter = $exception->response?->header('Retry-After');
if ($retryAfter) {
return (int) $retryAfter * 1000; // convert to ms
}
}
return $baseDelay * (2 ** ($attempt - 1));
}, function (\Exception $exception) {
// Only retry when rate limited
return $exception instanceof \Illuminate\Http\Client\RequestException
&& $exception->response?->status() === 429;
})->get('https://api.example.com/data');
Explanation of Exponential Backoff: Instead of retrying immediately (which will get another 429), the client waits progressively longer: 1 second → 2 seconds → 4 seconds. Combined with the Retry-After header for exact wait times.
Rate Limiting Queue Jobs
Sometimes you need to throttle queue job processing speed (e.g., calling an external API with its own rate limit):
// app/Jobs/SendNewsletterJob.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Queue\Middleware\RateLimited;
class SendNewsletterJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $subscriberId,
) {}
/**
* Queue middleware: limit to 100 emails/minute
*/
public function middleware(): array
{
return [
new RateLimited('newsletter'),
];
}
public function handle(): void
{
// Send email...
}
/**
* If rate limited, retry after 60 seconds
*/
public function retryAfter(): int
{
return 60;
}
}
Define the limiter:
// AppServiceProvider
RateLimiter::for('newsletter', function () {
return Limit::perMinute(100);
});
Rate Limiting External API Calls
// app/Jobs/SyncToExternalApiJob.php
use Illuminate\Queue\Middleware\RateLimitedWithRedis;
class SyncToExternalApiJob implements ShouldQueue
{
public function middleware(): array
{
// RateLimitedWithRedis is more accurate than RateLimited
// when running multiple workers concurrently
return [
(new RateLimitedWithRedis('external-api'))
->dontRelease(), // If rate limited, delete the job instead of releasing back to queue
];
}
}
Monitoring Rate Limits
Knowing who's being rate limited helps you tune thresholds appropriately:
// app/Http/Middleware/LogRateLimitHits.php
namespace App\Http\Middleware;
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->getStatusCode() === 429) {
Log::warning('Rate limit hit', [
'ip' => $request->ip(),
'user' => $request->user()?->id,
'path' => $request->path(),
'method' => $request->method(),
'limit' => $response->headers->get('X-RateLimit-Limit'),
]);
}
return $response;
}
}
API Documentation — Inform Your Consumers
Always clearly document rate limits in your API docs:
{
"rate_limits": {
"free": {
"requests_per_minute": 60,
"requests_per_day": 1000,
"note": "Shared across all endpoints"
},
"pro": {
"requests_per_minute": 300,
"requests_per_day": 10000,
"note": "Per API key"
},
"enterprise": {
"requests_per_minute": 1000,
"requests_per_day": 100000,
"note": "Custom limits available"
}
},
"headers": {
"X-RateLimit-Limit": "Maximum requests allowed",
"X-RateLimit-Remaining": "Requests remaining in window",
"Retry-After": "Seconds until limit resets (only on 429)"
},
"best_practices": [
"Cache responses when possible",
"Use webhooks instead of polling",
"Implement exponential backoff on 429",
"Contact us for higher limits"
]
}
Summary: Choosing the Right Strategy
| Scenario | Strategy |
|---|---|
| Public API | Named limiter + IP-based |
| Authenticated API | Plan-based limiter + User ID |
| Login/Password reset | Combined IP + email, low limit |
| File upload | Per-user, low limit |
| Data export | Per-user, per-hour |
| Queue jobs calling external APIs | RateLimitedWithRedis middleware |
| SaaS multi-tier | Array limits (per-minute + per-day) |
| High traffic bursts | Sliding window limiter |
Principles:
- Start generous, tighten when you see abuse
- Always return headers so clients know their limits
- Custom response messages that are clear and user-friendly
- Monitor who's being limited to adjust thresholds
- Document clearly for API consumers