Skip to content

Rate limiting

Throttling by route, through the throttle middleware.

php
Route::post('form/submit', 'FormController@handle')->middleware('throttle');

Three ways to say how much

Written asMeans
throttle60 requests per minute
throttle:2020 per minute
throttle:100,5100 per 5 minutes
throttle:loginthe named limiter login

The inline forms cover most cases. A named limiter is for when the limit depends on the request.

Named limiters

Define one once, use it by name:

php
use Boshnik\PageBlocks\Routing\Limit;
use Boshnik\PageBlocks\Support\RateLimiter;

RateLimiter::for('login', function ($request) {
    return Limit::perMinute(5)->by($request->ip());
});
php
Route::post('login', 'AuthController@login')->middleware('throttle:login');

Building a limit

php
Limit::perSecond(2);          // 2 per second
Limit::perMinute(60);         // 60 per minute
Limit::perMinutes(5, 100);    // 100 per 5 minutes
Limit::perHour(1000);         // 1000 per hour
Limit::perDay(5000);          // 5000 per day
Limit::none();                // no limit at all

->by($key) chooses the bucket — an IP, a user id, an API token. Without it the bucket is the caller's IP.

Limit::none() is how a limiter says "not this one": returning it from the callback lets the request through, which is easier to read than a conditional around the middleware.

A limit that depends on who is asking

The callback receives the request, so the limit can differ per caller:

php
RateLimiter::for('api', function ($request) {
    return $request->user()
        ? Limit::perMinute(1000)->by('user:' . $request->user()->id)
        : Limit::perMinute(60)->by($request->ip());
});

Several limits at once

Return an array and every limit is checked; the first one exceeded wins:

php
RateLimiter::for('sms', fn($request) => [
    Limit::perMinute(3)->by($request->ip()),
    Limit::perDay(20)->by($request->ip()),
]);

That is the usual shape for anything that costs money per call — a burst limit plus a daily ceiling.

What the caller sees

Over the limit: 429, with Too Many Attempts. as JSON if the request expects JSON and as plain text otherwise.

Headers are on every response, not only the rejected ones:

HeaderMeaning
X-RateLimit-LimitThe ceiling
X-RateLimit-RemainingHow many are left
Retry-AfterSeconds until the window resets — only on a 429
X-RateLimit-ResetTimestamp of the reset — only on a 429

A custom response is a chain away:

php
Limit::perMinute(5)->response(fn($request, $retryAfter) =>
    response()->json(['error' => 'slow_down', 'retry_after' => $retryAfter], 429)
);

The rate headers are added to your response too, so you cannot forget them.

Buckets

The counter key is throttle|scope|by.

  • For a named limiter, the scope is the name — so one limiter shared by five routes counts them together. That is usually what you want for login, and usually not what you want for unrelated endpoints.
  • For an inline limit, the scope is the method plus the path — so each route counts separately.

An undefined limiter lets the request through

throttle:typo writes a warning to the MODX log and allows the request. It fails open, on the grounds that a typo in a middleware name should not take a form offline — but it does mean a misspelt limiter is silently no protection at all. Check the log after adding one.

Manual use

The counter is available directly, for cases that are not a route:

php
RateLimiter::tooManyAttempts($key, 5);   // over the limit?
RateLimiter::hit($key, 60);              // count one attempt, 60s window
RateLimiter::remaining($key, 5);
RateLimiter::availableIn($key);          // seconds until reset
RateLimiter::resetAttempts($key);

Counters live in the MODX cache under pageblocks/ratelimit, so clearing the site cache clears them.

© PageBlocks 2019-present