Skip to main content
Laravel, shipping fast.

Protected routes are grouped with middleware in a specific order:

// routes/api.php
Route::get('/health', HealthController::class);  // Public

Route::middleware([
    EnsureTokenIsValidMiddleware::class,
    'throttle:api-token',
    AddRateLimitHeadersMiddleware::class,
    LogApiRequestsMiddleware::class,
])->controller(LicenseController::class)->group(function () {
    Route::get('/licenses', 'show');
    Route::post('/license', 'create');
    Route::delete('/license/{license}', 'destroy');
});

Order matters. Middleware runs top to bottom on the request, bottom to top on the response:

Request path:

  1. EnsureTokenIsValidMiddleware: Validate token exists, not expired, domain matches. If fails, reject with 401. Stop here.
  2. throttle:api-token: Check if token exceeded rate limit. If yes, return 429. Stop here.
  3. AddRateLimitHeadersMiddleware: Add rate limit headers to response.
  4. LogApiRequestsMiddleware: Queue a log job.
  5. Controller: Execute the actual endpoint logic.

Response path:

  1. Response travels back through LogApiRequestsMiddleware (logging happens asynchronously)
  2. Through AddRateLimitHeadersMiddleware (headers already added)
  3. Through throttle middleware (rate limit tracking recorded)
  4. Through EnsureTokenIsValidMiddleware (nothing to do on response)
  5. Back to client

This ordering ensures failed requests stop early. A request with no token never reaches rate limiting. A rate-limited request never reaches your controller. This is efficient and predictable.

Rate Limiting: Per-Token Throttling

Laravel's built-in throttle middleware handles rate limiting. But the key is configuring it per-token, not per-IP:

// config/rate-limiting.php
RateLimiter::for('api-token', function (Request $request) {
    // Rate limit by token, not IP
    // Same token, same limit, regardless of origin IP
    // Different tokens, independent limits
    $token = (string) $request->bearerToken();

    if (! $token) {
        return Limit::perMinute(0);  // No token? Zero requests
    }

    return Limit::perMinute(60)
        ->by($token);  // Identify by token, not IP
});

Why per-token instead of per-IP? IPs change. A legitimate client might be behind a load balancer that rotates IPs. Token owners stay the same. A token either has the credentials or it doesn't. Quota is tied to the token, not where the request comes from.

When a token exceeds its limit, the throttle middleware:

  1. Returns HTTP 429 (Too Many Requests)
  2. Stops request processing (never reaches your controller)
  3. Includes Retry-After header telling the client when to retry

Example response:

HTTP/1.1 429 Too Many Requests
Retry-After: 15
Content-Type: application/json

{"message":"Too Many Requests"}

The rate limit math:

  • 60 requests per minute per token
  • That's 1 request per second on average
  • Burst requests allowed up to the per-minute cap
  • After 60, every request is rejected until the minute window passes

This prevents clients from destroying your API through misconfigured loops or denial-of-service attempts. One client's runaway job can't starve others.

Exposing Rate Limit Information

Clients can react proactively if they know their quota. Add a middleware that exposes rate limit headers:

// app/Http/Middleware/AddRateLimitHeadersMiddleware.php
class AddRateLimitHeadersMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        // Get rate limiter instance
        $key = "throttle:api-token:" . ($request->bearerToken() ?? 'none');

        /**  @var \Illuminate\Cache\RateLimiter $limiter */
        $limiter = Container::getInstance()->make(RateLimiter::class);

        $limits = $limiter->limiter('api-token');

        // Add rate limit headers
        $response->headers->set('X-RateLimit-Limit', '60');
        $response->headers->set('X-RateLimit-Remaining', $limits->remaining());
        $response->headers->set('X-RateLimit-Reset', $limits->resetAt());

        return $response;
    }
}

Clients see:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1645276800

Now they know: 60 requests per minute, 45 remaining, window resets at that Unix timestamp. Smart clients can:

  1. Check remaining quota before making requests
  2. Back off early to avoid hitting the wall
  3. Retry intelligently when hitting limits
  4. Monitor quota usage to detect problems

This transforms rate limiting from "suddenly rejected" to "I can plan around this."