Rate limiting
Throttling by route, through the throttle middleware.
Route::post('form/submit', 'FormController@handle')->middleware('throttle');Three ways to say how much
| Written as | Means |
|---|---|
throttle | 60 requests per minute |
throttle:20 | 20 per minute |
throttle:100,5 | 100 per 5 minutes |
throttle:login | the 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:
use Boshnik\PageBlocks\Routing\Limit;
use Boshnik\PageBlocks\Support\RateLimiter;
RateLimiter::for('login', function ($request) {
return Limit::perMinute(5)->by($request->ip());
});Route::post('login', 'AuthController@login')->middleware('throttle:login');Building a limit
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:
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:
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:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The ceiling |
X-RateLimit-Remaining | How many are left |
Retry-After | Seconds until the window resets — only on a 429 |
X-RateLimit-Reset | Timestamp of the reset — only on a 429 |
A custom response is a chain away:
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:
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.