Skip to content
OpenCloseEsc
Blog

5 min read

Rate limiting in Laravel: we built it ourselves, then Laravel shipped it

We needed limits per minute, per hour and per day, so we wrote a middleware with three cache counters. It had a race, wrong headers and a day that reset at 3:47 PM. Laravel's RateLimiter does all of it in three lines.

A few years ago we needed rate limits for our API. Not just "60 requests per minute". We needed per minute, per hour, and per day, all at the same time. A client could call us 60 times a minute, but not more than 1,000 times an hour, and not more than 10,000 times a day.

Laravel at that time gave us throttle:60,1. One number, one window. Not enough.

So we wrote our own.

What we built

A middleware. It read the user's API key, built three cache keys, and checked three counters.

Something like this (cleaned up, it was uglier):

public function handle(Request $request, Closure $next)
{
    $key = $request->user()->api_key;
 
    $limits = [
        'minute' => [60,    60],
        'hour'   => [1000,  3600],
        'day'    => [10000, 86400],
    ];
 
    foreach ($limits as $name => [$max, $seconds]) {
        $cacheKey = "rate:{$name}:{$key}";
        $count = Cache::get($cacheKey, 0);
 
        if ($count >= $max) {
            return response()->json(['message' => 'Too many requests'], 429);
        }
 
        Cache::put($cacheKey, $count + 1, $seconds);
    }
 
    return $next($request);
}

It worked. Mostly.

Problems came later:

  • get then put is not atomic. Two requests at the same time both read 59, both write 60. The limit was soft.
  • The Retry-After header was wrong, or missing. Clients had no idea when to try again.
  • The day window started at the first request, not at midnight. Nobody understood why their limit reset at 3:47 PM.
  • Every new limit was more code in the same middleware.

We fixed some of it. We lived with the rest.

What Laravel ships now

Then I looked at the rate limiting docs again and realized: all of this is built in. It has been for a while, and I just never went back to check.

Here is the whole thing.

Define a limiter

In AppServiceProvider::boot():

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
 
RateLimiter::for('api', function (Request $request) {
    $id = $request->user()?->id ?: $request->ip();
 
    return [
        Limit::perMinute(60)->by('minute:'.$id),
        Limit::perHour(1000)->by('hour:'.$id),
        Limit::perDay(10000)->by('day:'.$id),
    ];
});

That's our whole middleware. Three lines.

Note the prefixes minute:, hour:, day:. If you give three limits the same by key, they share one counter and break each other. The prefix keeps them apart. The docs mention this, but it's easy to miss.

Attach it to routes

Route::middleware('throttle:api')->group(function () {
    Route::get('/orders', [OrderController::class, 'index']);
    Route::post('/orders', [OrderController::class, 'store']);
});

When a client hits the limit, Laravel returns 429 with Retry-After and X-RateLimit-* headers. The headers we never got right, for free.

The windows you can use

Limit::perSecond(10);
Limit::perMinute(60);
Limit::perHour(1000);
Limit::perDay(10000);
Limit::perMinutes(5, 100);  // 100 requests per 5 minutes — note: decay first, then max
Limit::none();              // no limit, useful for VIP users

perMinutes has the arguments in the other order from the rest. I don't know why. Just be careful.

Things our version never did

Different limits for different users

RateLimiter::for('api', function (Request $request) {
    $user = $request->user();
 
    if ($user?->plan === 'enterprise') {
        return Limit::none();
    }
 
    if ($user) {
        return Limit::perMinute(100)->by($user->id);
    }
 
    return Limit::perMinute(10)->by($request->ip());
});

Logged-in users get 100 a minute. Guests get 10. Enterprise gets no limit. In our old middleware this would have been another if block and another cache key format.

Limit by something else than the user

A login route should be limited by email, not by IP. Otherwise one attacker behind many IPs can try one account forever.

RateLimiter::for('login', function (Request $request) {
    return [
        Limit::perMinute(500),                                  // global, for the whole app
        Limit::perMinute(5)->by($request->input('email')),      // per account
    ];
});

by() takes any string. User id, email, API key, tenant id, whatever makes sense.

Your own 429 response

return Limit::perMinute(60)
    ->by($request->ip())
    ->response(function (Request $request, array $headers) {
        return response()->json([
            'error' => 'rate_limited',
            'message' => 'Slow down. Try again in '.$headers['Retry-After'].' seconds.',
        ], 429, $headers);
    });

Count only some responses

This one is newer and I like it a lot. You can decide after the response which requests count.

RateLimiter::for('lookup', function (Request $request) {
    return Limit::perMinute(10)
        ->by($request->ip())
        ->after(function (Response $response) {
            return $response->status() === 404;
        });
});

Only 404s count. Someone guessing IDs gets blocked fast. Normal users who get 200s are never touched.

Same trick for validation: don't count 422s, so a user fixing a form doesn't eat their own limit.

Use Redis in production

By default the counters live in your cache driver. With file or database cache, the increment is not atomic — the same race we had in our own code.

With Redis, Laravel can use Lua scripts and the count is exact. Turn it on in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->throttleWithRedis();
})

And set CACHE_STORE=redis. That's it.

Testing it

public function test_api_is_rate_limited(): void
{
    $user = User::factory()->create();
 
    for ($i = 0; $i < 60; $i++) {
        $this->actingAs($user)->getJson('/api/orders')->assertOk();
    }
 
    $this->actingAs($user)
        ->getJson('/api/orders')
        ->assertStatus(429)
        ->assertHeader('Retry-After');
}

Use the array cache driver in phpunit.xml so counters reset between tests.

What I learned

The feature was there. I didn't look.

Our custom middleware cost us a few days to write and a lot of small bugs over two years. The built-in version took me an afternoon to switch to, and it handled things we never got around to: correct headers, atomic counters, per-user rules, and limits that only count failures.

Before you build something yourself, open the docs for the current version. Not the version you learned on. Laravel moves. Sometimes the thing you need shipped two releases ago.