In Laravel, how does RateLimiter::attempt() limit an arbitrary action such as sending an SMS weather alert, and what does it return?
answer
- key, max attempts, callback, decay
- decay defaults to 60 seconds
- false when out of attempts
- callback result, or true for null
- availableIn() for the wait
basics
~20 sRateLimiter::attempt($key, $maxAttempts, $callback, $decaySeconds = 60) runs the callback and counts a hit only while attempts remain. It returns false when the limit is reached, otherwise the callback's return value, or true if that is null.
solid answer
~30 s`RateLimiter::attempt('sms-alert:'.$subscriber->id, 3, fn () => $this->sendSms($subscriber), 3600)` allows three alerts per subscriber per hour. It first calls `tooManyAttempts()`; if the key is exhausted it returns `false` without running the callback. Otherwise it runs the callback, records a hit with the given decay (default **60 seconds**), and returns the callback's result — or `true` when the callback returns `null`. The key is any string you choose, so include the identity you limit by. Related calls: `tooManyAttempts($key, $max)`, `hit()` / `increment()`, `remaining($key, $max)`, `availableIn($key)` for the seconds to wait, and `clear($key)` to reset. Counters live in the cache, like route throttling.
code
php · 15 lines<?php
use Illuminate\Support\Facades\RateLimiter;
$sent = RateLimiter::attempt(
'sms-alert:'.$subscriber->id,
3,
fn () => $sms->send($subscriber->phone, $alert->text),
3600,
);
if ($sent === false) {
$wait = RateLimiter::availableIn('sms-alert:'.$subscriber->id);
logger()->info("SMS alert skipped; next slot in {$wait}s");
}go deeper
Recall the four arguments of attempt(), the 60-second default decay, and that false means the limit was reached.
Explain the check-run-hit order, the true-for-null return, and the lower-level calls such as availableIn() and clear().
Design keys that include action and identity, handle callbacks that may return false, and guard strict ceilings against concurrent workers.
Decide where application-level limits belong versus provider-side quotas, and how limited actions degrade for users when a ceiling is hit.
## Limiting something that is not a route The `throttle` middleware limits HTTP requests. Many things worth limiting are not requests: a weather service may send SMS alerts from a queued job, retry a paid upstream API, or let users trigger a report email. `Illuminate\Support\Facades\RateLimiter` exposes the same counters directly. ## `attempt()` step by step ```php $sent = RateLimiter::attempt( 'sms-alert:'.$subscriber->id, 3, fn () => $sms->send($subscriber->phone, $alert->text), 3600, ); ``` The signature is `attempt($key, $maxAttempts, Closure $callback, $decaySeconds = 60)`: 1. If `tooManyAttempts($key, $maxAttempts)` is true, return **`false`** immediately; the callback does not run. 2. Otherwise run the callback. 3. Record a hit for the key with the given decay. 4. Return the callback's result, or **`true`** if the callback returned `null`. So `3` and `3600` mean "at most three alerts per subscriber per hour", with the hour starting at the first alert of the window. ## Reading the return value | Callback returns | `attempt()` returns | |---|---| | nothing (`null`) | `true` | | a value, e.g. a message id | that value | | — limit already reached | `false`, callback not called | One trap follows: if your callback can legitimately return `false`, you cannot tell "ran and returned false" from "was throttled". Return something else, or check `tooManyAttempts()` yourself. ## The lower-level calls When `attempt()` does not fit — for example when you only want to count failures — use the building blocks: - `RateLimiter::tooManyAttempts($key, $max)` — is the key exhausted? - `RateLimiter::hit($key, $decaySeconds = 60)` or `increment($key, $decaySeconds = 60, $amount = 1)` — count one or more attempts. - `RateLimiter::remaining($key, $max)` — attempts left in the window. - `RateLimiter::availableIn($key)` — seconds until the window resets, for messages such as "try again in 12 minutes". - `RateLimiter::clear($key)` — reset the counter and its timer, for example after an admin override. ```php if (RateLimiter::tooManyAttempts('report:'.$user->id, 5)) { $wait = RateLimiter::availableIn('report:'.$user->id); return back()->withErrors(['report' => "Try again in {$wait} seconds."]); } RateLimiter::hit('report:'.$user->id, 600); ``` ## Choosing keys - Put the **action and the identity** in the key: `sms-alert:{subscriber}`, `report:{user}`. A key without an identity limits everybody together. - Keys are plain strings shared across the whole app. Two features that pick the same key share a counter, so prefix by feature. - Keys from user input (an email address, a station code) are fine for counting but let an attacker spread attempts over many values; pair them with a per-IP or per-account key where abuse matters. ## `attempt()` versus the `throttle` middleware | Aspect | `RateLimiter::attempt()` | `throttle:name` middleware | |---|---|---| | What is limited | Any callback: an SMS, an upstream call, an export | HTTP requests to a route | | Where the rule lives | At the call site | In `RateLimiter::for()` | | Over the limit | Returns `false`; you decide what happens | Throws a 429 with headers | | Key | Any string you build | Limiter name plus `by()` value | | Decay unit | Seconds (default 60) | Set by the `Limit` builder | Both share the same `RateLimiter` class and cache store, so a key built for `attempt()` never collides with a route limiter's hashed key unless you deliberately reuse it. ## Caveats - **Check, then count.** `attempt()` checks the counter, runs the callback and only then records the hit. Two workers running the same key at the same moment can both pass the check. For a hard ceiling under concurrency, combine limiting with a lock or make the action idempotent. - **A failing callback is not counted.** If the callback throws, no hit is recorded, so retries of a failing SMS send are not limited by `attempt()` itself. - **Storage.** Counters live in the cache store named by `cache.limiter`, or the default store; the store must be shared by every server and worker that runs the action.
- How do you tell a user how long to wait after RateLimiter::attempt() returns false?Call `RateLimiter::availableIn($key)` with the same key. It returns the seconds until the window's timer expires, which you can show directly or format as minutes. `RateLimiter::remaining($key, $max)` gives the attempts left while the key is not yet exhausted.
- Why can two queue workers both get past RateLimiter::attempt() for the same key at the limit?`attempt()` checks the counter, runs the callback, and records the hit only afterwards. Two workers that check at the same moment both see one attempt left, both run the callback, and both count. Where the ceiling must be strict, guard the action with a lock or make it idempotent.
attempt() works like a punch card at a coffee counter: the barista looks at the card first, refuses once it is full, and only punches it after serving the drink.
saying these in an interview costs you the question
- attempt() throws an exception when the limit is reached
- attempt() counts the hit before running the callback
- The decay argument of attempt() is in minutes
- A callback that returns null makes attempt() return null
- RateLimiter keys are automatically scoped per user