Tested and maintained, so you don't have to. $99/year.
Throttle
Put one line in front of a login, an OTP send, or a contact form and Flick counts attempts per client, blocking the ones that hammer it. limit() records the attempt and answers in one call — true means blocked, so the guard clause reads without negation.
Why Use This Service?
A public form with no rate limit is an open invitation: bots hammer a contact form, a login page, or an OTP send endpoint until something gives, whether that's your inbox, your mail quota, or an account's password.
A session-based counter doesn't stop them. A visitor who drops their cookie gets a brand new session, and with it a brand new counter — the same reason OTP's own cooldown resets when the session does. Rate limiting has to live somewhere the client can't reset on a whim.
One line in front of any form fixes it. Throttle counts attempts server-side, keyed by client identity, and blocks the ones that go over.
Installation
Flick Pro requires a license. See Pro installation for setup instructions.
Throttle needs no configuration — the default file storage works out of the box.
Configuration
All keys are optional:
$config = [ 'services' => [ 'throttle' => [ 'storage' => 'file', // 'file' (default), 'sql', or a Flick\Throttle\Storage\CounterStorageInterface instance 'max' => 5, // Attempts allowed per window 'per' => 'minutes:1', // Window length, unit:N format 'table' => 'throttle', // 'sql' storage only ], ], ]; $form = new Flick($config);
| Option | Type | Default | Description |
|---|---|---|---|
storage |
string, or instance |
'file' |
Where counters are kept: 'file', 'sql', or a CounterStorageInterface instance |
max |
int |
5 |
Attempts allowed per window |
per |
string |
'minutes:1' |
Window length, unit:N format |
table |
string |
'throttle' |
Table name, 'sql' storage only |
Supported duration formats: seconds:N, minutes:N, hours:N, days:N, weeks:N, months:N, years:N
File Storage
The default. No database needed — counters live in locked files, and nothing a visitor can reset by dropping a cookie. Two things worth knowing: it's per-server, so a load-balanced setup with more than one web server won't share counters across them, and it's cleared on reboot. Both are fine for the short, minute-long windows throttle is built for — a login form doesn't need its counter to survive a server restart. Multi-server setups need 'storage' => 'sql'.
SQL Storage
Switch storage to the SQL service so every server shares the same counters:
$config = [ 'services' => [ 'throttle' => [ 'storage' => 'sql', ], 'sql' => [ /* your database config */ ], ], ];
Flick doesn't create the table for you — create it yourself. The column is id, not key — KEY is a reserved word in MySQL:
CREATE TABLE throttle ( id VARCHAR(64) NOT NULL, -- sha256 of limiter name + client identity attempts INTEGER NOT NULL DEFAULT 0, expires_at INTEGER NOT NULL, -- unix timestamp, window end PRIMARY KEY (id) );
To use a different table name, set table alongside storage: 'sql'.
Basic Usage
A Contact Form
One line in front of the form does everything:
if ($form->throttle->limit('contact')) { // Too many submits from this client. // Error bag ('throttle'): "Too many attempts. Try again in 45 seconds." }
A Login Form
Forgive the counter on success, so a user who typoed their password a few times isn't locked out by their own success:
if ($form->throttle->limit('login')) { // blocked; render the form, the error shows } elseif ($form->auth->verify($password, $user['password'])) { $form->throttle->clear('login'); $form->auth->login($user['id']); $form->redirect('/dashboard'); }
Per Account
Clients are told apart by IP (proxy-aware) by default. Pass key to limit per account instead — useful against an attacker rotating IPs at one account:
$form->throttle->limit('login', ['key' => $email, 'max' => 10, 'per' => 'minutes:5']);
In Front of OTP
if ($form->ok() && ! $form->throttle->limit('otp-send')) { $form->otp->send($email); }
Throttle guards the endpoint per client; OTP's own cooldown and attempt cap guard each identifier — both are already built in, so there's no need to add a second per-identifier throttle on top.
Methods
limit()
Record an attempt and answer whether this client is over the limit.
$form->throttle->limit(string $name, array $options = []): bool
| Parameter | Type | Description |
|---|---|---|
$name |
string |
The limiter's name — a form or endpoint identifier, e.g. 'login' |
$options |
array |
max (default 5), per (default 'minutes:1'), key (replaces the client IP as the identity) |
Returns: true if this client is over the limit — a retry message is added to the error bag under 'throttle'. false if the client can proceed.
Tip
Blocked hits keep counting but never extend the window, so the retry time Flick promises stays true.
clear()
Forgive the counter — call after a success (correct password, valid code), so a legitimate user's failed tries don't outlive their success.
$form->throttle->clear(string $name, array $options = []): void
| Parameter | Type | Description |
|---|---|---|
$name |
string |
The limiter's name |
$options |
array |
key |
retryIn()
Seconds until the block lifts, or null while the client is under the limit. Read-only — never counts a hit.
$form->throttle->retryIn(string $name, array $options = []): ?int
| Parameter | Type | Description |
|---|---|---|
$name |
string |
The limiter's name |
$options |
array |
max, key |
Returns: Seconds remaining, or null if the client hasn't reached the limit.
if ($seconds = $form->throttle->retryIn('login')) { echo "Locked. Try again in {$seconds} seconds."; }
Security Features
- Hashed Keys: Storage keys are sha256 hashes of the limiter name plus client identity — raw IPs and emails never persist in files or rows
- Fixed Windows: Blocked hits keep counting but never extend the window, so the promised retry time stays accurate
- Fail-Open File Storage: The default storage keeps a form usable even under filesystem pressure, rather than locking every visitor out if a counter write fails
- Proxy-Aware Identity: Client IPs come from Flick's core request handling, which already accounts for trusted proxies — throttle doesn't second-guess it