Per-user limits, per-IP limits, per-endpoint throttling, dynamic rate limits based on user tier, Redis-backed limiters that survive horizontal scaling, and the response headers that tell your clients exactly when to retry — the complete guide.
Rate limiting is the category of protection that does nothing until the day it does everything. No one notices it working. Every day it’s not configured is a day your authentication endpoint is accepting unlimited password attempts, your AI-powered features are accepting unlimited expensive API calls, and your search endpoint is accepting unlimited complex queries from a single IP. Then something happens — a credential stuffing attack, a misbehaving integration, a user who found the API endpoint for your most expensive operation — and either your rate limiter handles it or your infrastructure does, expensively and slowly.
Laravel’s rate limiting system has been production-grade since the RateLimiter facade arrived in Laravel 8. In 2026 it runs on Redis by default in any serious deployment, integrates with the named limiter system introduced in Laravel 8, and composes with middleware cleanly enough that protecting a new route takes one line. This post covers the full stack: from the simplest throttle middleware to dynamic per-tier limits that read from the database, Redis configuration that survives horizontal scaling, and the response headers that make your rate limiting observable by the clients consuming your API.
How the Rate Limiter Works
Before the API: the mechanics. Laravel’s rate limiter uses a sliding window algorithm backed by atomic Redis operations. When a request arrives:
1. Compute the limiter key (user ID, IP, or custom combination)
2. Increment the counter for this key in Redis (atomic INCR)
3. If this is the first hit in the window, set TTL on the key (EXPIRE)
4. If the counter exceeds the limit, return 429
5. If the counter is within the limit, proceed and attach rate limit headers
The atomic increment-then-expire pattern means two concurrent requests can’t both “win” a race to check the counter before either increments it. This is the correct implementation for a distributed system. A PHP-native rate limiter using MySQL would require transactions and row locks to achieve the same guarantee — and at the request rates where rate limiting matters, the database contention would become the bottleneck.
Named Limiters — The Right Way to Configure Rate Limits
The throttle:60,1 shorthand works. Named limiters are how you build rate limiting you can actually maintain:
// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
// Simple per-IP limit for public endpoints
RateLimiter::for('public-api', function (Request $request) {
return Limit::perMinute(60)->by($request->ip());
});
// Per-user limit for authenticated endpoints
RateLimiter::for('authenticated-api', function (Request $request) {
return Limit::perMinute(120)->by($request->user()?->id ?? $request->ip());
});
// Per-endpoint per-user limit — tighter, for expensive operations
RateLimiter::for('search', function (Request $request) {
return Limit::perMinute(30)->by(
'search:' . ($request->user()?->id ?? $request->ip())
);
});
// Strict limit for authentication — credential stuffing protection
RateLimiter::for('auth', function (Request $request) {
return [
Limit::perMinute(5)->by($request->ip()), // IP-based
Limit::perMinute(3)->by('email:' . $request->input('email')), // email-based
];
});
}
The auth limiter returns two limits: one per IP and one per email address. Both must pass for the request to proceed. An attacker rotating IPs is still rate-limited by the per-email limit. An attacker rotating email addresses is still rate-limited by the per-IP limit. A credential stuffing attack with many IPs and many emails hits both limits and gets throttled on whichever fires first.
Apply named limiters to routes:
// routes/api.php
Route::middleware('throttle:authenticated-api')->group(function () {
Route::get('/projects', [ProjectController::class, 'index']);
Route::post('/projects', [ProjectController::class, 'store']);
});
Route::middleware('throttle:auth')->group(function () {
Route::post('/login', [AuthController::class, 'login']);
Route::post('/password/forgot', [PasswordController::class, 'forgot']);
});
Route::get('/search', [SearchController::class, 'index'])
->middleware(['auth:sanctum', 'throttle:search']);
Dynamic Rate Limits Based on User Tier
The flat per-user limit is the starting point. For a multi-tier SaaS with Free, Pro, and Enterprise plans, the limit should reflect the tier:
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
// Unauthenticated requests: most restrictive
if (! $user) {
return Limit::perMinute(10)->by($request->ip());
}
// Authenticated: limit based on subscription tier
$limit = match($user->subscription_tier) {
'enterprise' => 1000,
'pro' => 300,
'free' => 60,
default => 60,
};
return Limit::perMinute($limit)->by($user->id);
});
The match expression reads $user->subscription_tier — a column on the users table. For a SaaS with many users at different tiers, hitting the database on every request to check the tier is expensive. Cache the tier:
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
if (! $user) {
return Limit::perMinute(10)->by($request->ip());
}
$tier = Cache::remember(
"user:{$user->id}:tier",
now()->addMinutes(10),
fn () => $user->subscription_tier
);
$limit = match($tier) {
'enterprise' => 1000,
'pro' => 300,
'free' => 60,
default => 60,
};
return Limit::perMinute($limit)->by($user->id);
});
When a user upgrades their plan, clear the cache:
// In the plan upgrade handler
Cache::forget("user:{$user->id}:tier");
The 10-minute cache window means a newly upgraded user sees their higher limit within 10 minutes without the tier lookup hitting the database on every API request.
Per-Endpoint Cost-Based Limiting
Not all endpoints are equal. A GET /users query costs one database query. A POST /reports/generate costs 45 database queries, 8 seconds of CPU, and an S3 upload. Rate limiting both at the same rate makes no sense.
The cost-based approach assigns a weight to each endpoint and counts against a shared budget:
RateLimiter::for('weighted-api', function (Request $request) {
$user = $request->user();
// Budget: 1000 "points" per minute per user
// Each endpoint costs a different number of points
$costs = [
'GET:/api/users' => 1,
'GET:/api/projects' => 1,
'POST:/api/projects' => 5,
'GET:/api/reports' => 10,
'POST:/api/reports/generate' => 50,
'POST:/api/ai/complete' => 20,
];
$key = $request->method() . ':' . $request->path();
$cost = $costs[$key] ?? 5;
// Limit::perMinute() doesn't support non-integer costs natively,
// so we use the RateLimiter facade directly for cost-based limiting
// and return a null limit (handled below via middleware)
return Limit::perMinute(1000 / $cost)->by($user?->id ?? $request->ip());
});
For more granular control, use the RateLimiter facade directly in a middleware or controller:
// In a middleware or before a controller action
class CostBasedThrottle
{
private array $costs = [
'api/reports/generate' => 50,
'api/ai/complete' => 20,
'api/exports' => 30,
];
public function handle(Request $request, Closure $next, int $budget = 1000): Response
{
$user = $request->user();
$key = "cost-budget:{$user->id}";
$cost = $this->costs[$request->path()] ?? 1;
$current = RateLimiter::attempts($key);
if ($current + $cost > $budget) {
$retryAfter = RateLimiter::availableIn($key);
return response()->json([
'message' => 'Rate limit exceeded.',
'retry_after' => $retryAfter,
], 429)->withHeaders([
'X-RateLimit-Limit' => $budget,
'X-RateLimit-Remaining' => max(0, $budget - $current),
'Retry-After' => $retryAfter,
]);
}
// Increment by cost, not by 1
for ($i = 0; $i < $cost; $i++) {
RateLimiter::hit($key, 60);
}
return $next($request);
}
}
Redis Configuration for Horizontal Scaling
The default Laravel cache driver in a single-server deployment is often file or database. Neither works for rate limiting across multiple servers:
Server A: user hits /api/search, counter incremented on Server A's file cache
Server B: same user hits /api/search, counter starts at 0 on Server B's file cache
Result: user gets 2× the allowed requests, rate limiter provides no protection
Redis is the correct backend for rate limiting in any horizontally scaled deployment:
// config/cache.php
'default' => env('CACHE_DRIVER', 'redis'),
'stores' => [
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
'lock_connection' => 'default',
],
],
# .env
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null
For Redis Cluster or Sentinel deployments, the rate limiter works without modification — Laravel’s Redis driver handles cluster-aware key routing. The only consideration: in a Redis Cluster, ensure rate limit keys for the same user hash to the same slot. Prefix the keys with a hash tag:
RateLimiter::for('api', function (Request $request) {
$userId = $request->user()?->id ?? 'guest';
// Hash tag {} ensures this key hashes to the same Redis slot
return Limit::perMinute(120)->by("{user:{$userId}}:api");
});
The Response Headers Your Clients Need
A 429 response without context is an error. A 429 response with headers is a contract. The client knows the limit, how many requests remain, and when to retry:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
Retry-After: 47
X-RateLimit-Reset: 1720000847
Laravel’s throttle middleware adds X-RateLimit-Limit and X-RateLimit-Remaining automatically for successful requests. The Retry-After header on 429 responses is also automatic. What you might want to add manually:
// Custom 429 response with full header set
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' => 'Too many requests.',
'retry_after' => $headers['Retry-After'],
], 429, $headers);
});
});
The ->response() callback receives the $headers array that Laravel computed for the 429 response, including Retry-After, X-RateLimit-Limit, and X-RateLimit-Remaining. You can return a custom JSON body while still sending the correct headers.
For clients that need to implement exponential backoff, the X-RateLimit-Reset epoch timestamp tells them exactly when the window resets rather than requiring them to track elapsed time from Retry-After:
->response(function (Request $request, array $headers) {
$resetAt = now()->addSeconds($headers['Retry-After'])->timestamp;
return response()->json([
'message' => 'Too many requests.',
'retry_after' => (int) $headers['Retry-After'],
'reset_at' => $resetAt,
], 429, array_merge($headers, [
'X-RateLimit-Reset' => $resetAt,
]));
});
Rate Limiting Outside of HTTP — Jobs and Commands
Rate limiting isn’t only for HTTP requests. Queue jobs that call external APIs need rate limiting to stay within third-party provider limits. Artisan commands that run data migrations need rate limiting to avoid saturating the database.
Rate limiting a queued job:
class SyncExternalData implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
// External API allows 100 requests per minute
// Release the job back to the queue if we're at the limit
if (RateLimiter::tooManyAttempts('external-api-sync', 100)) {
$seconds = RateLimiter::availableIn('external-api-sync');
$this->release($seconds);
return;
}
RateLimiter::hit('external-api-sync', 60);
// Make the external API call
$this->performSync();
}
}
$this->release($seconds) puts the job back on the queue with a delay. The job retries when the rate limit window resets. The job’s $tries counter is not incremented — releasing is not the same as failing.
Rate limiting an Artisan command:
class ImportLegacyData extends Command
{
public function handle(): int
{
$records = LegacyRecord::where('imported', false)->cursor();
$processed = 0;
foreach ($records as $record) {
// Process at most 500 records per minute to avoid DB saturation
if (RateLimiter::tooManyAttempts('legacy-import', 500)) {
$wait = RateLimiter::availableIn('legacy-import');
$this->info("Rate limit reached. Waiting {$wait} seconds...");
sleep($wait);
}
RateLimiter::hit('legacy-import', 60);
$this->processRecord($record);
$processed++;
}
$this->info("Processed {$processed} records.");
return self::SUCCESS;
}
}
The Limit::none() Pattern for Bypassing Limits
Internal services, admin users, and health check endpoints should bypass rate limiting:
RateLimiter::for('api', function (Request $request) {
// Internal service calls from trusted IPs bypass all limits
if (in_array($request->ip(), config('services.trusted_ips', []))) {
return Limit::none();
}
// Admin users bypass rate limits
if ($request->user()?->hasRole('admin')) {
return Limit::none();
}
return Limit::perMinute(120)->by($request->user()?->id ?? $request->ip());
});
Limit::none() explicitly disables rate limiting for the request. The route still passes through the rate limit middleware — it just always succeeds.
Observability — Knowing When Limits Are Being Hit
A rate limiter you can’t observe is a rate limiter you can’t tune. Log 429 responses with enough context to distinguish between a misbehaving client and an attacker:
// app/Http/Middleware/LogRateLimitExceeded.php
class LogRateLimitExceeded
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($response->status() === 429) {
Log::warning('Rate limit exceeded', [
'user_id' => $request->user()?->id,
'ip' => $request->ip(),
'endpoint' => $request->method() . ' ' . $request->path(),
'user_agent' => $request->userAgent(),
'headers' => $response->headers->all(),
]);
}
return $response;
}
}
With this in place, you can query the logs for:
High 429 rate from a single IP → credential stuffing or scraping
High 429 rate from a single user → misbehaving integration or abuse
High 429 rate on a specific endpoint → limit too tight for legitimate usage
The last case — limit too tight — is the one most teams discover after deployment. A mobile app that polls an endpoint every 30 seconds hits a per-minute limit of 5 immediately. Logging 429s by endpoint tells you which limits need adjustment without requiring you to reproduce the issue.
Metrics via Prometheus or similar:
// Register a custom counter in a service provider
public function boot(): void
{
$this->app->booted(function () {
// If using a metrics package, increment on every 429
if (app()->bound('metrics')) {
app('metrics')->counter('rate_limit_exceeded_total', [
'endpoint' => request()->path(),
'tier' => request()->user()?->subscription_tier ?? 'guest',
]);
}
});
}
Testing Rate Limits
Rate limit tests are easy to forget and important to have. Two categories: that the limit is enforced, and that it resets correctly.
// tests/Feature/RateLimitTest.php
use Illuminate\Support\Facades\RateLimiter;
it('returns 429 after exceeding the search rate limit', function () {
$user = User::factory()->create();
// Clear any existing rate limit data
RateLimiter::clear('search:' . $user->id);
// Make 30 requests (the limit)
for ($i = 0; $i < 30; $i++) {
$this->actingAs($user)
->getJson('/api/search?q=test')
->assertOk();
}
// The 31st request should be rate limited
$this->actingAs($user)
->getJson('/api/search?q=test')
->assertStatus(429)
->assertJsonPath('message', 'Too many requests.')
->assertHeader('Retry-After');
});
it('includes correct rate limit headers on successful responses', function () {
$user = User::factory()->create();
RateLimiter::clear('search:' . $user->id);
$response = $this->actingAs($user)
->getJson('/api/search?q=test');
$response->assertOk()
->assertHeader('X-RateLimit-Limit', '30')
->assertHeader('X-RateLimit-Remaining', '29');
});
it('resets the rate limit after the window expires', function () {
$user = User::factory()->create();
// Exhaust the limit
for ($i = 0; $i < 30; $i++) {
RateLimiter::hit('search:' . $user->id, 60);
}
// Verify it's hit
expect(RateLimiter::tooManyAttempts('search:' . $user->id, 30))->toBeTrue();
// Clear the limiter (simulates window expiry)
RateLimiter::clear('search:' . $user->id);
// Verify it reset
expect(RateLimiter::tooManyAttempts('search:' . $user->id, 30))->toBeFalse();
});
it('applies stricter limits to free tier users than pro users', function () {
$freeUser = User::factory()->create(['subscription_tier' => 'free']);
$proUser = User::factory()->create(['subscription_tier' => 'pro']);
RateLimiter::clear($freeUser->id);
RateLimiter::clear($proUser->id);
// Free user hits their limit at 60 requests
for ($i = 0; $i < 60; $i++) {
$this->actingAs($freeUser)->getJson('/api/projects')->assertOk();
}
$this->actingAs($freeUser)->getJson('/api/projects')->assertStatus(429);
// Pro user still has headroom at 60 requests
for ($i = 0; $i < 60; $i++) {
$this->actingAs($proUser)->getJson('/api/projects')->assertOk();
}
$this->actingAs($proUser)->getJson('/api/projects')->assertOk(); // not limited yet
});
The Limits That Protect the Things You Haven’t Thought About
Password reset endpoint. You thought about limiting login. Did you limit password reset? An attacker who submits 10,000 password reset requests for a real user’s email address doesn’t compromise the account — but they do flood the user’s inbox and trigger 10,000 emails from your mail provider. Per-email limit on password reset: 3 per hour.
Export endpoint. Generating a CSV of 50,000 records is expensive. One authenticated user who discovers the export endpoint and runs it in a loop every 5 seconds will max out your server’s memory. Per-user limit on exports: 5 per hour.
Webhook delivery endpoint. Your system receives webhooks from external providers. A misconfigured provider fires the same webhook 200 times. Per-source limit on webhook ingestion: 50 per minute, return 429 for the rest — most providers will retry later.
File upload endpoint. Unlimited uploads from a single user can fill your S3 bucket. Per-user per-day upload limit with a size budget is separate from rate limiting (it’s a quota) but belongs in the same mental model: what happens if a single user does this at maximum volume?
AI feature endpoints. Every AI call costs money. If you have any AI-powered endpoint — completion, summarisation, classification — it needs a rate limit independent of the general API limit. An authenticated user who discovers the AI endpoint and calls it 10,000 times has a non-trivial bill impact. Per-user per-day limit on AI calls is the cost control that prevents a single user from causing a budget anomaly.
// Named limiters for the endpoints teams forget
RateLimiter::for('password-reset', function (Request $request) {
return Limit::perHour(3)->by('reset:' . $request->input('email'));
});
RateLimiter::for('exports', function (Request $request) {
return Limit::perHour(5)->by($request->user()->id);
});
RateLimiter::for('webhooks', function (Request $request) {
return Limit::perMinute(50)->by('webhook:' . $request->ip());
});
RateLimiter::for('ai', function (Request $request) {
return [
Limit::perMinute(10)->by($request->user()->id), // burst protection
Limit::perDay(200)->by('ai-daily:' . $request->user()->id), // budget protection
];
});
The Configuration That Belongs in Every New Laravel Project
Rate limiting set up before the first feature is shipped, not after the first incident:
// AppServiceProvider::boot() — default configuration for every project
// Public API — unauthenticated endpoints
RateLimiter::for('public', function (Request $request) {
return Limit::perMinute(30)->by($request->ip());
});
// Authenticated API — general purpose
RateLimiter::for('api', function (Request $request) {
if (! $request->user()) {
return Limit::perMinute(30)->by($request->ip());
}
return Limit::perMinute(120)->by($request->user()->id);
});
// Authentication endpoints — strict
RateLimiter::for('auth', function (Request $request) {
return [
Limit::perMinute(5)->by($request->ip()),
Limit::perMinute(3)->by('email:' . $request->input('email', '')),
];
});
// Expensive operations
RateLimiter::for('heavy', function (Request $request) {
return Limit::perHour(10)->by($request->user()?->id ?? $request->ip());
});
// AI features
RateLimiter::for('ai', function (Request $request) {
return [
Limit::perMinute(10)->by($request->user()->id),
Limit::perDay(200)->by('ai:' . $request->user()->id),
];
});
Routes applied:
// routes/api.php
Route::middleware('throttle:api')->group(function () { /* main API routes */ });
Route::middleware('throttle:heavy')->group(function () { /* exports, reports, imports */ });
Route::middleware(['auth:sanctum', 'throttle:ai'])->group(function () { /* AI endpoints */ });
// routes/web.php or routes/auth.php
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:auth');
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:auth');
Route::post('/forgot-password', [PasswordController::class, 'store'])->middleware('throttle:auth');
This is the configuration that most applications should have on day one. It doesn’t cover every case — cost-based limiting, per-tier dynamic limits, and webhook ingestion are added as the need arises. But it covers the attacks that happen before you’ve thought about them.
