Skip to content
OpenCloseEsc
Blog

4 min read

I wrote a rate limiter Laravel already had

A 190-line class with three windows, tests and hand-built headers, for an app on Laravel 8.83. Limit::perHour and perDay had been in vendor/ since 2020, and a named limiter can return all three at once.

A while back I needed to throttle a few sensitive endpoints in a Laravel app: login, customer registration, technician registration. One limit per minute wasn't enough. A bot that stays under 60 requests a minute can still make 80,000 attempts a day. So I wanted three windows at once: per minute, per hour, per day.

I looked at the throttle middleware, saw throttle:60,1, assumed it only did one window, and did what developers do. I wrote a class.

The class I wrote

It was about 190 lines. A fluent builder, three windows with defaults, a blocked() method that looped the windows and checked RateLimiter::tooManyAttempts() for each one, a hit() that incremented each window, a clear(), a hand-rolled 429 response with a Retry-After header, and a headers() method that built X-RateLimit-Minute-Remaining and friends.

Usage looked like this:

$blocked = MultiRateLimiter::for($request, 'customer.register')
    ->by($request->ip())
    ->perMinute(5)
    ->perHour(20)
    ->perDay(50)
    ->attempt();
 
if ($blocked) {
    return $blocked;
}

Honestly, it was fine. It worked. It had tests. I was a little proud of it.

What Laravel actually ships

Then I was reading through the framework source for an unrelated reason and opened Illuminate\Cache\RateLimiting\Limit. Here is what is in there:

Limit::perMinute($maxAttempts)
Limit::perMinutes($decayMinutes, $maxAttempts)
Limit::perHour($maxAttempts, $decayHours = 1)
Limit::perDay($maxAttempts, $decayDays = 1)
Limit::none()

And the part I had missed completely: a named rate limiter can return an array of limits, and the throttle middleware enforces all of them.

// app/Providers/RouteServiceProvider.php
 
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
 
RateLimiter::for('register', function (Request $request) {
    return [
        Limit::perMinute(5)->by($request->ip()),
        Limit::perHour(20)->by($request->ip()),
        Limit::perDay(50)->by($request->ip()),
    ];
});

Then on the route:

Route::post('register', [CustomerRegisterController::class, 'store'])
    ->middleware('throttle:register');

That is the whole thing. No class, no manual hit(), no loop. The middleware checks every limit, increments every limit, and returns a 429 with Retry-After and X-RateLimit-Reset when any one of them is exceeded.

The things I thought I needed the class for

Every feature I had built turned out to have a built-in equivalent.

Custom key. I was hashing the action name plus the IP or user id. ->by() does the same job:

Limit::perMinute(10)->by(optional($request->user())->id ?: $request->ip())

Custom 429 response. My class returned a JSON envelope in the project's standard format. ->response() takes a closure:

Limit::perMinute(5)
    ->by($request->ip())
    ->response(function (Request $request, array $headers) {
        return Res::_error('Too many requests. Please try again later.', 429)
            ->withHeaders($headers);
    })

Rate limit headers. I was building per-window headers by hand. The middleware already sends X-RateLimit-Limit and X-RateLimit-Remaining. With several limits it reports the one with the fewest remaining attempts, which is the only number the client actually cares about.

Turning it off for some routes. Limit::none() from inside the closure. I was already using this for webhook routes and somehow still didn't connect the dots.

What about login?

Login is the one place where the middleware is not quite right, because you usually want to count failed attempts only and reset on success. The middleware counts every request.

But even here you do not need a class. The RateLimiter facade does it directly:

$key = 'login:' . $request->ip();
 
if (RateLimiter::tooManyAttempts($key, 5)) {
    $seconds = RateLimiter::availableIn($key);
    return Res::_error("Try again in {$seconds} seconds.", 429);
}
 
if (! Auth::attempt($credentials)) {
    RateLimiter::hit($key, 60);
    return back()->withErrors(['email' => 'Wrong credentials.']);
}
 
RateLimiter::clear($key);

If you want hour and day windows on login too, hit two more keys with different decay times. Three lines, not a class.

"This must be a new feature"

It is not. I checked. perHour and perDay were contributed in Laravel 8.7, back in 2020. Multiple limits per named limiter are documented in the 8.x rate limiting docs. The app I built the class for was on Laravel 8.83. The feature was sitting in vendor/ the whole time.

Laravel 11 later added Limit::perSecond() as well, so now the full range is covered.

What I took from this

Read the framework class before writing your own. Not the docs page, the actual class. The docs for rate limiting are short and I had skimmed them. The source for Limit is under 150 lines and lists every option in one screen.

I am deleting the class. The replacement is a limiter definition in the service provider and a middleware string on each route. Less code to own, less code to test, and the next person who reads the route file sees throttle:register and knows exactly what it means.