NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Enrolment is two steps: start a pending enrolment, then confirm it with the first code from the user’s authenticator app. Nothing counts as a second factor until it is confirmed.

Start

use RoundlyConsulting\TwoFactor\Facades\TwoFactor;

$setup = TwoFactor::for($user)->start();

$setup->secret;          // base32 secret (store is handled for you)
$setup->provisioningUri; // otpauth://totp/Acme:[email protected]?secret=...&issuer=Acme&...
$setup->recoveryCodes;   // list<string> — show these once, they are the only plaintext copy
$setup->issuer;          // the issuer the URI carries — show it next to the QR
PropertyTypeContents
secretstringThe base32 secret — already persisted, encrypted.
provisioningUristringThe otpauth:// URI to render as a QR code.
recoveryCodeslist<string>Plaintext recovery codes — the only copy, show them once.
issuerstringThe issuer the URI carries — show it next to the QR.

start() generates a fresh secret and recovery codes, persists them with confirmed_at cleared and fires TwoFactorEnrolmentStarted. Starting again while pending restarts it with a new secret and new codes; starting when 2FA is already enabled throws TwoFactorAlreadyEnabledException.

Issuer per guard or tenant

The issuer defaults to two-factor.issuer, then app.name — a blank value at either level counts as unset. Brand it per call with the issuer argument — one name per guard or tenant — and override the account label the same way; the URI and $setup->issuer always agree:

$setup = TwoFactor::for($client)->start(label: '[email protected]', issuer: 'Acme Partner Portal');

Confirm

The user scans the QR code and submits the first code to finish enrolment:

TwoFactor::for($user)->confirm($request->string('code')->toString());
// throws InvalidTwoFactorCodeException on a wrong code,
// TwoFactorNotPendingException if there is no pending enrolment — including when 2FA is
// already enabled: a confirm is never a silent no-op, so a clean return always means this
// code just switched 2FA on.

A successful confirm stamps confirmed_at, fires TwoFactorConfirmed and claims the confirming timestep in the replay guard, so the code used to confirm cannot double as the first login code.

From the user model

The same flow is available as trait verbs, with an optional label and issuer. They delegate to TwoFactor::for($user), so TwoFactor::fake() sees them too:

$setup = $user->startTwoFactorEnrolment();                  // == TwoFactor::for($user)->start()
$setup = $user->startTwoFactorEnrolment('[email protected]', issuer: 'Acme Billing');

$user->confirmTwoFactor($request->string('code')->toString()); // == ->confirm($code)

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.