Introduction to API Rate Limiting
Rate limiting is a critical security and performance mechanism that controls how many requests a client can make to your API within a specific time window. It protects your API from abuse, prevents resource exhaustion, and ensures fair usage across all clients. In this comprehensive lesson, we'll explore Laravel's built-in rate limiting features and learn how to implement custom throttling strategies.
Why Rate Limiting Matters
Before diving into implementation, let's understand why rate limiting is essential for any production API:
Benefits of Rate Limiting:
- DoS Protection: Prevents denial-of-service attacks by limiting excessive requests
- Resource Management: Protects server resources (CPU, memory, database connections)
- Fair Usage: Ensures equitable API access for all users
- Cost Control: Reduces infrastructure costs by preventing abuse
- Service Quality: Maintains consistent performance for legitimate users
- Monetization: Enables tiered pricing based on usage limits
Laravel's Built-in Throttle Middleware
Laravel provides a powerful throttle middleware out of the box. Let's start with basic usage:
<?php
// routes/api.php
use Illuminate\Support\Facades\Route;
// Basic throttling: 60 requests per minute
Route::middleware(['throttle:60,1'])->group(function () {
Route::get('/users', [UserController::class, 'index']);
Route::get('/posts', [PostController::class, 'index']);
});
// Different rate for authenticated users
Route::middleware(['auth:sanctum', 'throttle:100,1'])->group(function () {
Route::post('/posts', [PostController::class, 'store']);
Route::put('/posts/{id}', [PostController::class, 'update']);
});
// Per-user throttling (rate limit per authenticated user)
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::get('/profile', [ProfileController::class, 'show']);
});
</pre>
Understanding Throttle Parameters
The throttle middleware accepts several parameter formats:
// Format: throttle:max_attempts,decay_minutes
// 60 requests per minute
'throttle:60,1'
// 1000 requests per hour (60 minutes)
'throttle:1000,60'
// 10000 requests per day (1440 minutes)
'throttle:10000,1440'
// Use named rate limiter (defined in RouteServiceProvider)
'throttle:api'
// Dynamic rate based on user attribute
'throttle:rate_limit,1'
Custom Rate Limiters
Laravel allows you to define custom rate limiters in App\Providers\RouteServiceProvider. This is where the real power lies:
<?php
// app/Providers/RouteServiceProvider.php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Default API rate limiter
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip());
});
// Tiered rate limiting based on user subscription
RateLimiter::for('tiered', function (Request $request) {
$user = $request->user();
if (!$user) {
// Anonymous users: 10 requests per minute
return Limit::perMinute(10)->by($request->ip());
}
// Rate limit based on user's plan
return match ($user->subscription_plan) {
'free' => Limit::perMinute(50)->by($user->id),
'pro' => Limit::perMinute(200)->by($user->id),
'enterprise' => Limit::perMinute(1000)->by($user->id),
default => Limit::perMinute(30)->by($user->id),
};
});
// Multiple rate limits (combine per-minute and per-hour)
RateLimiter::for('strict', function (Request $request) {
return [
Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()),
Limit::perHour(1000)->by($request->user()?->id ?: $request->ip()),
];
});
// API endpoint-specific rate limiting
RateLimiter::for('expensive', function (Request $request) {
return Limit::perMinute(5)
->by($request->user()?->id ?: $request->ip())
->response(function (Request $request, array $headers) {
return response()->json([
'error' => 'Too many expensive operations. Please try again later.',
'retry_after' => $headers['Retry-After'] ?? 60,
], 429, $headers);
});
});
}
}
</pre>
Pro Tip: Use the by() method to specify how rate limits are tracked. Common strategies include tracking by user ID, IP address, API key, or a combination of attributes.
Rate Limit Response Headers
Laravel automatically adds rate limit information to response headers. Understanding these headers helps clients implement proper retry logic:
// Response headers when rate limiting is active:
X-RateLimit-Limit: 60 // Maximum attempts allowed
X-RateLimit-Remaining: 47 // Remaining attempts in window
Retry-After: 45 // Seconds until rate limit resets
X-RateLimit-Reset: 1708012800 // Unix timestamp when limit resets
Here's how to consume these headers in your API client:
<?php
// Example API client handling rate limits
class ApiClient
{
private int $remainingRequests = 0;
private int $resetTimestamp = 0;
public function makeRequest(string $url, array $data = []): array
{
// Check if we're rate limited
if ($this->remainingRequests === 0 && time() < $this->resetTimestamp) {
$waitSeconds = $this->resetTimestamp - time();
throw new RateLimitException("Rate limited. Retry in {$waitSeconds} seconds.");
}
$response = Http::withToken($this->apiKey)->get($url, $data);
// Update rate limit tracking from headers
$this->remainingRequests = (int) $response->header('X-RateLimit-Remaining');
$this->resetTimestamp = (int) $response->header('X-RateLimit-Reset');
if ($response->status() === 429) {
$retryAfter = (int) $response->header('Retry-After', 60);
throw new RateLimitException("Rate limit exceeded. Retry after {$retryAfter} seconds.");
}
return $response->json();
}
}
</pre>
Advanced Rate Limiting Patterns
1. Dynamic Rate Limits Based on Request Cost
Some operations are more expensive than others. Implement cost-based rate limiting:
<?php
// app/Providers/RouteServiceProvider.php
RateLimiter::for('cost-based', function (Request $request) {
$user = $request->user();
$cost = $request->route()?->getAction('cost') ?? 1;
return Limit::perMinute(100)
->by($user?->id ?: $request->ip())
->response(function () use ($cost) {
return response()->json([
'error' => 'Rate limit exceeded',
'cost' => $cost,
], 429);
});
});
// routes/api.php
Route::get('/search', [SearchController::class, 'search'])
->middleware('throttle:cost-based')
->defaults('cost', 5); // This endpoint costs 5 points
Route::get('/users', [UserController::class, 'index'])
->middleware('throttle:cost-based')
->defaults('cost', 1); // This endpoint costs 1 point
</pre>
2. Burst Protection with Sliding Windows
Prevent burst attacks while allowing normal usage:
<?php
RateLimiter::for('burst-protection', function (Request $request) {
return [
// Allow bursts: 10 requests per second
Limit::perSecond(10)->by($request->user()?->id ?: $request->ip()),
// Sustained rate: 100 requests per minute
Limit::perMinute(100)->by($request->user()?->id ?: $request->ip()),
// Daily cap: 10,000 requests per day
Limit::perDay(10000)->by($request->user()?->id ?: $request->ip()),
];
});
</pre>
3. IP-Based and User-Based Combination
Track both IP and user for comprehensive protection:
<?php
RateLimiter::for('combined', function (Request $request) {
$user = $request->user();
if ($user) {
// Authenticated users: track by user ID and IP
return [
Limit::perMinute(100)->by($user->id),
Limit::perMinute(200)->by($request->ip()), // Per IP (multiple users)
];
}
// Anonymous users: stricter IP-based limits
return Limit::perMinute(20)->by($request->ip());
});
</pre>
Custom Throttle Middleware
For complete control, create a custom throttle middleware:
<?php
// app/Http/Middleware/ApiThrottle.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
class ApiThrottle
{
public function handle(Request $request, Closure $next, int $maxAttempts = 60): Response
{
$key = $this->resolveRequestSignature($request);
$maxAttempts = $this->resolveMaxAttempts($request, $maxAttempts);
$decaySeconds = 60;
// Get current attempt count
$attempts = Cache::get($key, 0);
if ($attempts >= $maxAttempts) {
$retryAfter = $this->getRetryAfter($key, $decaySeconds);
return response()->json([
'error' => 'Too Many Requests',
'message' => "Rate limit of {$maxAttempts} requests per minute exceeded.",
'retry_after' => $retryAfter,
], 429)->withHeaders([
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => 0,
'Retry-After' => $retryAfter,
'X-RateLimit-Reset' => time() + $retryAfter,
]);
}
// Increment attempt count
$this->incrementAttempts($key, $decaySeconds);
$response = $next($request);
// Add rate limit headers
return $this->addHeaders(
$response,
$maxAttempts,
$maxAttempts - $attempts - 1,
$decaySeconds
);
}
protected function resolveRequestSignature(Request $request): string
{
if ($user = $request->user()) {
return 'throttle:' . $user->id;
}
return 'throttle:' . $request->ip();
}
protected function resolveMaxAttempts(Request $request, int $default): int
{
// Allow premium users higher limits
if ($user = $request->user()) {
return $user->rate_limit ?? $default;
}
return $default;
}
protected function incrementAttempts(string $key, int $decaySeconds): void
{
if (Cache::has($key)) {
Cache::increment($key);
} else {
Cache::put($key, 1, $decaySeconds);
}
}
protected function getRetryAfter(string $key, int $decaySeconds): int
{
return Cache::get($key . ':timer', $decaySeconds);
}
protected function addHeaders(Response $response, int $limit, int $remaining, int $reset): Response
{
return $response->withHeaders([
'X-RateLimit-Limit' => $limit,
'X-RateLimit-Remaining' => max(0, $remaining),
'X-RateLimit-Reset' => time() + $reset,
]);
}
}
</pre>
Database-Driven Rate Limiting
For enterprise applications, store rate limits in the database:
<?php
// Migration
Schema::create('api_rate_limits', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('endpoint');
$table->integer('max_attempts')->default(60);
$table->integer('decay_minutes')->default(1);
$table->timestamps();
$table->unique(['user_id', 'endpoint']);
});
// Model
class ApiRateLimit extends Model
{
protected $fillable = ['user_id', 'endpoint', 'max_attempts', 'decay_minutes'];
public function user()
{
return $this->belongsTo(User::class);
}
}
// Usage in RouteServiceProvider
RateLimiter::for('database', function (Request $request) {
$user = $request->user();
$endpoint = $request->route()?->getName();
if ($user && $endpoint) {
$rateLimit = ApiRateLimit::where('user_id', $user->id)
->where('endpoint', $endpoint)
->first();
if ($rateLimit) {
return Limit::perMinute($rateLimit->max_attempts)
->by($user->id);
}
}
// Default fallback
return Limit::perMinute(60)->by($request->ip());
});
</pre>
Warning: Database-driven rate limiting adds query overhead on every request. Cache rate limit configurations aggressively to minimize database hits.
Monitoring and Analytics
Track rate limit violations for security and capacity planning:
<?php
// app/Listeners/LogRateLimitHit.php
namespace App\Listeners;
use Illuminate\Auth\Events\RateLimitHit;
use Illuminate\Support\Facades\Log;
class LogRateLimitHit
{
public function handle(RateLimitHit $event): void
{
Log::warning('Rate limit exceeded', [
'ip' => request()->ip(),
'user_id' => auth()->id(),
'endpoint' => request()->path(),
'user_agent' => request()->userAgent(),
]);
// Optionally store in database for analytics
RateLimitViolation::create([
'user_id' => auth()->id(),
'ip_address' => request()->ip(),
'endpoint' => request()->path(),
'violated_at' => now(),
]);
}
}
// Register in EventServiceProvider
protected $listen = [
\Illuminate\Auth\Events\RateLimitHit::class => [
LogRateLimitHit::class,
],
];
</pre>
Testing Rate Limiters
Write tests to ensure your rate limiting works correctly:
<?php
// tests/Feature/RateLimitingTest.php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RateLimitingTest extends TestCase
{
use RefreshDatabase;
public function test_rate_limiting_works_for_guests()
{
// Make 60 requests (should succeed)
for ($i = 0; $i < 60; $i++) {
$response = $this->getJson('/api/users');
$response->assertStatus(200);
}
// 61st request should be rate limited
$response = $this->getJson('/api/users');
$response->assertStatus(429);
$response->assertHeader('Retry-After');
}
public function test_authenticated_users_have_higher_limits()
{
$user = User::factory()->create(['subscription_plan' => 'pro']);
// Make 200 requests (pro plan limit)
for ($i = 0; $i < 200; $i++) {
$response = $this->actingAs($user)->getJson('/api/posts');
$response->assertStatus(200);
}
// 201st request should be rate limited
$response = $this->actingAs($user)->getJson('/api/posts');
$response->assertStatus(429);
}
public function test_rate_limit_headers_are_present()
{
$response = $this->getJson('/api/users');
$response->assertHeader('X-RateLimit-Limit');
$response->assertHeader('X-RateLimit-Remaining');
$response->assertHeader('X-RateLimit-Reset');
}
}
</pre>
Practice Exercise:
- Create a custom rate limiter that allows free users 50 requests/minute, pro users 200 requests/minute, and enterprise users unlimited access
- Implement a "cost-based" rate limiting system where expensive operations (like complex searches) consume more rate limit quota
- Add rate limit violation logging to your API and create a dashboard to visualize which endpoints are being rate limited most frequently
- Write a middleware that implements a "sliding window" rate limiter instead of Laravel's default fixed window
- Create an API client class in JavaScript/TypeScript that respects rate limit headers and automatically retries requests with exponential backoff
Best Practices
Rate Limiting Best Practices:
- Choose appropriate limits: Balance security with usability based on your API's typical usage patterns
- Communicate limits clearly: Document rate limits in your API documentation
- Use descriptive error messages: Help clients understand when they can retry
- Implement tiered limits: Reward authenticated users and paid plans with higher limits
- Monitor violations: Track rate limit hits to identify abuse or insufficient limits
- Consider burst allowances: Allow short bursts while enforcing sustained rate limits
- Cache rate limit configurations: Avoid database queries on every request
- Test thoroughly: Ensure rate limiting doesn't block legitimate traffic
Summary
In this lesson, you've learned how to implement comprehensive rate limiting for your Laravel API. You now understand Laravel's built-in throttle middleware, how to create custom rate limiters, implement tiered limits based on user subscriptions, handle rate limit response headers, and monitor violations. Rate limiting is essential for building robust, secure, and scalable APIs that protect your infrastructure while providing fair access to all users.