Flick
Guide Validation Services API Pro Examples GitHub
Docs / Services
Flick Pro Service

Tested and maintained, so you don't have to. $99/year.

Get Flick Pro

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_DEFAULT before 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_DEFAULT before 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 (default seconds: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 of send(). OTP's per-identifier caps above are built in, so don't add another per-identifier throttle