Enrolment
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| Property | Type | Contents |
|---|---|---|
secret | string | The base32 secret — already persisted, encrypted. |
provisioningUri | string | The otpauth:// URI to render as a QR code. |
recoveryCodes | list<string> | Plaintext recovery codes — the only copy, show them once. |
issuer | string | The 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 cryptoBy 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.