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:
- EnsureTokenIsValidMiddleware: Validate token exists, not expired, domain matches. If fails, reject with 401. Stop here.
- throttle:api-token: Check if token exceeded rate limit. If yes, return 429. Stop here.
- AddRateLimitHeadersMiddleware: Add rate limit headers to response.
- LogApiRequestsMiddleware: Queue a log job.
- Controller: Execute the actual endpoint logic.
Response path:
- Response travels back through LogApiRequestsMiddleware (logging happens asynchronously)
- Through AddRateLimitHeadersMiddleware (headers already added)
- Through throttle middleware (rate limit tracking recorded)
- Through EnsureTokenIsValidMiddleware (nothing to do on response)
- 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:
- Returns HTTP 429 (Too Many Requests)
- Stops request processing (never reaches your controller)
- Includes
Retry-Afterheader 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:
- Check remaining quota before making requests
- Back off early to avoid hitting the wall
- Retry intelligently when hitting limits
- Monitor quota usage to detect problems
This transforms rate limiting from "suddenly rejected" to "I can plan around this."