Rate Limiting and API Throttling in Laravel — Protecting Your App from Abuse

· 10 min read

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 window
  • X-RateLimit-Remaining: Requests remaining
  • Retry-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:

  1. Use a Redis Sorted Set (ZSET) with score = timestamp
  2. Each request adds a member to the set
  3. Remove all members older than now - windowSeconds
  4. Count remaining members = requests in the sliding window
  5. 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:

  1. Start generous, tighten when you see abuse
  2. Always return headers so clients know their limits
  3. Custom response messages that are clear and user-friendly
  4. Monitor who's being limited to adjust thresholds
  5. Document clearly for API consumers

Comments