Laravel API Rate-Limiting: Custom Limiters, Per-Route Strategies, and Header Contracts
#laravel #api #rate-limiting #pest #performance

Laravel API Rate-Limiting: Custom Limiters, Per-Route Strategies, and Header Contracts

3 min read Mohamed Said Mohamed Said

Beyond throttle:60,1: Real API Rate-Limiting in Laravel

The built-in throttle middleware is fine for a quick guard, but production APIs need more: per-plan quotas, per-endpoint budgets, graceful degradation, and headers clients can actually parse. Laravel's RateLimiter facade gives you all of that — most teams just never reach for it.


Registering Named Limiters

Define limiters in AppServiceProvider::boot() (or a dedicated RateLimitServiceProvider).

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('api', function (Request $request) {
        $user = $request->user();

        if ($user?->plan === 'enterprise') {
            return Limit::none(); // unlimited
        }

        return Limit::perMinute($user?->rate_limit ?? 60)
            ->by($user?->id ?? $request->ip());
    });

    RateLimiter::for('search', function (Request $request) {
        return [
            Limit::perMinute(30)->by($request->user()?->id ?? $request->ip()),
            Limit::perDay(5_000)->by($request->user()?->id ?? $request->ip()),
        ];
    });
}

Returning an array of Limit objects enforces multiple windows simultaneously — a per-minute burst cap and a daily quota. Laravel checks all of them; the first exceeded limit triggers the 429.


Attaching Limiters to Routes

// routes/api.php
Route::middleware('auth:sanctum', 'throttle:api')
    ->group(function () {
        Route::get('/users', [UserController::class, 'index']);
    });

Route::middleware('auth:sanctum', 'throttle:search')
    ->get('/search', [SearchController::class, 'index']);

The string passed to throttle: maps directly to the name you registered with RateLimiter::for().


Understanding the Response Headers

When a request is allowed, Laravel automatically appends:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 43

When the limit is hit (HTTP 429):

Retry-After: 47
X-RateLimit-Reset: 1718023847

Retry-After is in seconds; X-RateLimit-Reset is a Unix timestamp. Well-behaved clients should honour Retry-After before retrying. Document this contract explicitly — it saves support tickets.


Programmatic Checking Inside Business Logic

Sometimes you need to gate an expensive operation inside a controller or action, not at the routing layer.

use Illuminate\Support\Facades\RateLimiter;

class GenerateReportAction
{
    public function execute(User $user): Report
    {
        $key = 'report-generation:' . $user->id;

        if (RateLimiter::tooManyAttempts($key, maxAttempts: 5)) {
            $seconds = RateLimiter::availableIn($key);
            throw new TooManyRequestsException(
                "Try again in {$seconds} seconds."
            );
        }

        RateLimiter::hit($key, decaySeconds: 3600);

        return Report::generate($user);
    }
}

RateLimiter::hit() increments the counter and sets the TTL on first hit. availableIn() returns the seconds until the window resets — pipe that into your API error payload so clients can back off intelligently.


Clearing Limits on Successful Upgrade

When a user upgrades their plan mid-session, stale limits can frustrate them. Clear programmatically:

RateLimiter::clear('report-generation:' . $user->id);

This is also useful in tests — reset state between feature test cases rather than waiting for cache TTLs.


Testing Rate Limiters with Pest

use Illuminate\Support\Facades\RateLimiter;

it('returns 429 after exceeding the search limit', function () {
    $user = User::factory()->create();

    // Exhaust the limiter
    foreach (range(1, 30) as $_) {
        actingAs($user)->getJson('/api/search?q=test');
    }

    $response = actingAs($user)->getJson('/api/search?q=test');

    $response->assertStatus(429)
        ->assertHeader('Retry-After');
});

beforeEach(fn () => RateLimiter::clear('search:' . auth()->id()));

Always clear the limiter in beforeEach — cache bleeds between tests otherwise.


Key Takeaways

  • Use RateLimiter::for() with named limiters instead of raw throttle:N,M for any non-trivial API.
  • Return an array of Limit objects to enforce burst and daily quotas simultaneously.
  • Retry-After and X-RateLimit-Reset are your client contract — document them.
  • RateLimiter::hit() / tooManyAttempts() / availableIn() let you gate expensive operations inside business logic, not just at the HTTP layer.
  • Clear limiters in Pest's beforeEach to prevent cache state leaking between tests.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I apply multiple rate limit windows (e.g. per-minute and per-day) to the same route?
Yes. Return an array of Limit objects from your RateLimiter::for() callback. Laravel evaluates all of them and triggers a 429 as soon as any single limit is exceeded.
Q02 How do I prevent rate limiter state from leaking between Pest feature tests?
Call RateLimiter::clear('your-key') in a beforeEach block. Because limiters are backed by the cache driver, they persist across requests in the same test run unless explicitly cleared.
Q03 Does returning Limit::none() for enterprise users skip all header injection?
Yes. When Limit::none() is returned, the throttle middleware treats the request as unlimited and does not append X-RateLimit-* headers, so clients should not rely on those headers being present for all users.

Continue reading

More Articles

View all