Tested and maintained, so you don't have to. $99/year.
OTP
Send a one-time code to an email address, then check it against what the visitor types back in. The same send() and verify() pair covers passwordless login, confirming an email address, and a second factor on top of a password.
Why Use This Service?
What you'd build yourself: cryptographically random code generation, slow-hashing codes at rest, expiry and cleanup, a resend cooldown so a form can't be used to flood an inbox, an attempt cap that locks out guessing, and purpose scoping so a login code and a verify-email code for the same address don't collide.
Maintained for you: the hashing and expiry logic stays correct as PHP's cryptographic functions change.
What Pro provides:
- Cryptographically random, fixed-length codes generated with
random_int() - Codes slow-hashed with
PASSWORD_DEFAULTbefore they're stored — never as plain text - Automatic expiry, plus a resend cooldown per identifier and purpose
- An attempt cap that destroys the code and forces a fresh send
- Delivery through the Mail service — nothing extra to wire up
- Works with any storage: the session by default, or the SQL service for codes that need to survive across devices
Installation
Flick Pro requires a license. See Pro installation for setup instructions.
Configuration
OTP needs no configuration to start working. Add it to your services list — codes are stored in the session and sent through the mail service, so configure mail once and OTP is ready:
$config = [ 'services' => [ 'otp' => [] ] ]; $form = new Flick($config);
Full Configuration
$config = [ 'services' => [ 'otp' => [ 'length' => 6, 'expires' => 'minutes:5', 'cooldown' => 'seconds:60', 'maxAttempts' => 5, 'subject' => 'Your verification code', ], ], ];
| Option | Type | Default | Description |
|---|---|---|---|
codeStorage |
string, or instance |
'session' |
Where codes are stored: 'session', 'sql', or a CodeStorageInterface instance |
length |
int |
6 |
Code length in digits, from 4 to 10 |
expires |
string |
'minutes:5' |
How long a code stays valid |
cooldown |
string |
'seconds:60' |
Minimum time between resends for the same identifier and purpose |
maxAttempts |
int |
5 |
Wrong guesses allowed before the code is locked out |
subject |
string |
'Your verification code' |
Default email subject; override per-send with the subject option |
table |
string |
'otp_codes' |
Table name, 'sql' storage only |
Supported duration formats: seconds:N, minutes:N, hours:N, days:N, weeks:N, months:N, years:N
Code Storage
Session (the default). No database needed — codes live in the session for as long as the visit that requested them. Two things worth knowing: the resend cooldown resets with the session, so a new session gets a clean cooldown, and verification is same-session only — a code has to come back on the same session that requested it, so a link opened on a different device won't find it. Cross-device flows need 'sql'.
SQL. Switch storage to the SQL service for a code that survives across devices and sessions:
$config = [ 'services' => [ 'otp' => [ 'codeStorage' => 'sql', ], 'sql' => [ /* your database config */ ], ], ];
Flick doesn't create the table for you — create it yourself:
CREATE TABLE otp_codes ( identifier VARCHAR(255) NOT NULL, purpose VARCHAR(64) NOT NULL, hashed_code VARCHAR(255) NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, expires_at INTEGER NOT NULL, last_sent_at INTEGER NOT NULL, PRIMARY KEY (identifier, purpose) );
To use a different table name, set table alongside codeStorage: 'sql'.
Custom storage. To keep codes anywhere else, implement Flick\Otp\Storage\CodeStorageInterface and pass an instance as codeStorage:
interface CodeStorageInterface { // Return ['hashed_code' => string, 'attempts' => int, 'expires_at' => int, 'last_sent_at' => int] or null public function find(string $identifier, string $purpose): ?array; public function store(string $identifier, string $purpose, string $hashedCode, int $expiresAt, int $sentAt): void; public function incrementAttempts(string $identifier, string $purpose): void; public function destroy(string $identifier, string $purpose): void; // Called by send() as housekeeping. Delete a row only once it is past // both expires_at and last_sent_at + $cooldownSeconds - an expired code // still anchors the resend cooldown, so deleting on expiry alone would // let one identifier's send() clear another's cooldown. public function destroyExpired(int $cooldownSeconds): void; }
This is the mirror image of Auth's remember-me tokens: remember-me exists to outlive the session, so it defaults to sql; a code's whole life is one visit, so it defaults to the session.
Basic Usage
Passwordless Login
One page, two steps: no code pending yet, so ask for an email and send one; a code is pending, so ask for it and verify.
$form = new Flick(); if (! $form->otp->identifier()) { $form->create('Email[required,email]'); if ($form->submitted()) { $email = $form->request('email', ['required', 'email']); if ($form->ok()) { $form->otp->send($email); } } } else { $form->create('Code[required]'); if ($form->submitted()) { $code = $form->request('code', 'required'); if ($form->otp->verify($code)) { $user = $form->sql->find('users', ['email' => $form->otp->identifier()]); $form->auth->login($user['id']); $form->redirect('/dashboard'); } } }
Tip
On a failed verify(), the reason is already in the error bag under 'otp' — check $form->errors() to show it.
Verifying an Email Address
Pass a purpose other than the default 'login' to keep a verification code separate from a login code for the same address — each (identifier, purpose) pair holds its own live code.
$form = new Flick(); // Send the code, e.g. right after registration $form->otp->send($user['email'], [ 'purpose' => 'verify-email', 'subject' => 'Confirm your email address', ]); // Later, on the confirmation page $form->create('Code[required]'); if ($form->submitted()) { $code = $form->request('code', 'required'); if ($form->otp->verify($user['email'], $code, ['purpose' => 'verify-email'])) { $form->sql->save('users', [ 'id' => $user['id'], 'email_verified_at' => date('Y-m-d H:i:s'), ]); } }
Resending a Code
Call send() again with the same identifier and purpose — a fresh code replaces the old one. The cooldown ('seconds:60' by default) blocks resends that come too fast: send() returns false and the error bag gets "Please wait before requesting another code."
if ($form->submitted() && $form->request('action') === 'resend') { if (! $form->otp->send($email)) { // Still on cooldown - $form->errors() has the reason } }
Methods
send()
Generate, store, and email a one-time code.
$form->otp->send(string $identifier, array $options = []): bool
| Parameter | Type | Description |
|---|---|---|
$identifier |
string |
Where the code is sent — an email address |
$options |
array |
purpose (default 'login'), subject, message (with the {code} and {minutes} placeholders) |
Returns: false on cooldown — check the error bag under 'otp'. A mail transport failure is reported by the mail service under 'mail'.
Tip
Delivery goes through the Mail service, resolved lazily — a verify-only request never constructs it.
verify()
Verify a code and burn it. One-arg form checks the code against this session's pending send; two-arg form names the identifier explicitly.
$form->otp->verify(string $identifierOrCode, ?string $code = null, array $options = []): bool
| Parameter | Type | Description |
|---|---|---|
$identifierOrCode |
string |
The code (one-arg form), or the identifier (two-arg form) |
$code |
string|null |
The code, when passing the identifier explicitly |
$options |
array |
purpose (default 'login') |
Returns: true on a correct, unexpired code; false otherwise, with the reason in the error bag under 'otp'.
Tip
Checked in order: unknown code, expired (refused), attempt cap reached (locked out), wrong code (counts as an attempt), correct code (destroyed, returns true). A code is always single-use. An expired or locked-out row stays as the resend cooldown's anchor until housekeeping sweeps it.
identifier()
The identifier from this session's most recent send(), or null.
$form->otp->identifier(): ?string
Returns: The identifier string, or null if no code is pending.
Security Features
- Slow-Hashed At Rest: Codes are hashed with
PASSWORD_DEFAULTbefore storage, never kept as plain text - Single-Use: A code is dead the moment it's verified correctly, expires, or hits the attempt cap
- Purpose-Scoped: Each
(identifier, purpose)pair holds its own code, so a login code and a verify-email code for the same address never collide - Attempt Cap:
maxAttempts(default 5) wrong guesses locks the code out and forces a fresh send - Resend Cooldown:
cooldown(defaultseconds:60) blocks rapid-fire resends to the same identifier and purpose - Per-client throttling: Lives in the Throttle service: put
$form->throttle->limit('otp-send')in front ofsend(). OTP's per-identifier caps above are built in, so don't add another per-identifier throttle