The resend guard
Fixed-window limits cap volume but still let a "send again" button fire on every click until the cap is hit. The resend guard layers an escalating cooldown and a rolling cap on top: after each send the next one for that email is held back a little longer — 30 seconds, then 60, then 120, up to a ceiling — and no more than five go out per hour.
It is on by default and keyed per email, so it never depends on whether an account exists.
Turn it off with resend.enabled = false; tune it with the resend.* keys:
resend.enabled governs this package's request endpoint only. If your application uses the
guard for its own keys — see Reusing the guard —
those stay guarded whatever this switch says. Before 0.19.0 it was a global kill-switch, so
turning off magic-link throttling also disarmed your own.
'resend' => [
'enabled' => env('EMAIL_MAGIC_LINK_RESEND', true),
'cooldown' => [
'base' => 30,
'factor' => 2,
'max' => 900,
],
'window' => [
'minutes' => 60,
'max_sends' => 5,
],
'store' => null,
],
A held-back request is not a dead end
The caller is told how many seconds remain. Browsers get the count flashed to the session —
the bundled request screen disables the button and counts down — and JSON clients get a 429
with a Retry-After header:
{ "message": "Please wait 30 seconds before requesting another sign-in email.", "error": "resend_throttled" }
The resend_throttled code is stable and safe to branch on, while the human message stays
generic. It is translated in every bundled locale — see Translations.
Use it for your own endpoints
The same guard is a public service you can wrap around your own mail-sending endpoints —
a "resend code" button on a custom challenge, a re-invite, a password-reset resend. Inject the
EmailMagicLink\Contracts\ResendGuard contract and gate the send with a key of your choosing:
use EmailMagicLink\Contracts\ResendGuard;
public function __construct(private ResendGuard $guard) {}
public function resend(Request $request): Response
{
$decision = $this->guard->attempt('challenge:'.$request->user()->id);
if (! $decision->allowed) {
// Seconds until the next send is allowed — render a countdown.
return back()->with('retry_after', $decision->retryAfterSeconds);
}
// …send your mail…
}
A denied decision also carries reason, a ResendDenialReason of Cooldown or WindowCap,
so you can word the two cases differently. The full signatures are in the
contract reference.
A key you own is always guarded. resend.enabled switches off throttling on this package's
request endpoint and nothing else, so an operator disabling magic-link resends can never
silently disarm the flood protection on, say, your second-factor challenge.
The rules the guard follows
-
attempt($key)records a send only when it allows one. A denied attempt changes no state, so a client polling the endpoint cannot push its own cooldown out. Usepeek($key)to read the current decision without recording anything — handy for rendering a countdown before the user acts. -
reset($key)starts the key over. Both the cooldown ladder and the rolling window clear. The package calls this for its own key once a link or code issued for the address is verified, so a real sign-in is never punished; call it yourself after your own flow completes. -
Pick your own key namespace. Keys share the cache store, so prefix them (
challenge:{id},invite:{email}) to avoid colliding with the package's ownrequest:{email}keys or each other. Normalize an email key yourself if you mix casings. -
The store must support atomic locks. The guard takes a short lock per attempt so concurrent requests cannot each slip past the cap; the array, file, database, Redis, and Memcached stores all qualify. Point
resend.storeat one, or leave itnullfor the default cache store.On a store that is not a lock provider at all —
apc— the guard refuses to run. On thenullstore it used to fail open, which is the opposite, and it is worth knowing why:nullis a lock provider, and its lock succeeds every time. So the interface check passed, the guard evaluated its window under a lock that excluded nothing, and it threw nothing and throttled nothing — unlimited mail per address, quietly. It now rejects that lock by name. Nothing else aboutnullwas ever going to work here anyway: the counter it writes is discarded too. -
The state is a cache entry.
php artisan cache:clear(andoptimize:clear, which many deploy scripts run) resets every cooldown and every rolling window at once. Thearraystore holds the state for one PHP process only, so on it the guard takes its lock and throttles nothing — fine for a test, not for a deployment. A store that cannot hand out the lock in time holds the request back for a few seconds rather than erroring.
The hourly cap and availability
The cap is keyed on the submitted email, so an unauthenticated caller who knows an address can spend that address's hourly budget and keep the account from receiving a link for up to an hour. That is the nature of any per-account send cap — the same shape as the per-minute limiter, just over a longer window — and it is the price of guaranteeing a hard ceiling on mail to one inbox.
The guard runs after the CAPTCHA (see
Extension points), so in a
hostile setting, enabling the captcha guard stops an attacker from reaching the cap at all.
Raise resend.window.max_sends, shorten the window, or set resend.enabled = false if the
cooldown alone is enough for your threat model.