REST API Development

API Rate Limiting & Throttling

20 min Lesson 11 of 35

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:
  1. Create a custom rate limiter that allows free users 50 requests/minute, pro users 200 requests/minute, and enterprise users unlimited access
  2. Implement a "cost-based" rate limiting system where expensive operations (like complex searches) consume more rate limit quota
  3. Add rate limit violation logging to your API and create a dashboard to visualize which endpoints are being rate limited most frequently
  4. Write a middleware that implements a "sliding window" rate limiter instead of Laravel's default fixed window
  5. 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.