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

TwoFactor::fake() swaps the service for a programmable, no-crypto double and returns it as a TwoFactorFake for assertions. The facade, an injected TwoFactorService and the model verbs all see it, so you can assert your 2FA flow without freezing the clock or computing real codes:

use RoundlyConsulting\TwoFactor\Enums\TwoFactorMethod;
use RoundlyConsulting\TwoFactor\Facades\TwoFactor;

// attempt() only passes a user with confirmed 2FA, as in production. Enrol them first:
// under the fake, enrolment runs for real on its canned secret and accepts any code.
TwoFactor::fake();
TwoFactor::for($user)->start();
TwoFactor::for($user)->confirm('123456');

// Accept any code (the default) and assert the challenge was verified:
$fake = TwoFactor::fake()->accept();
$this->post('/login/2fa', ['code' => '123456'])->assertOk();
$fake->assertVerifiedFor($user);

// Reject every code:
TwoFactor::fake()->reject();
$this->post('/login/2fa', ['code' => '000000'])->assertStatus(422);

// Accept only a specific code:
TwoFactor::fake()->acceptCode('424242');

// Drive attempt(): pass via a recovery code, report 2 left, assert the method:
$fake = TwoFactor::fake()->acceptRecoveryCode()->withRemainingRecoveryCodes(2);
$this->post('/login/2fa', ['code' => 'ABCDE-12345'])->assertOk();
$fake->assertVerifiedVia(TwoFactorMethod::RecoveryCode);

// Fail as a replay (VerificationResult::$replayed === true):
TwoFactor::fake()->replay();

// Enrolment writes run for real on the fake's canned secret and codes, and are recorded:
$fake = TwoFactor::fake();
$this->post('/two-factor/disable')->assertOk();       // or $user->disableTwoFactor()
$this->post('/two-factor/enable')->assertOk();        // calls TwoFactor::for($user)->start()
$fake->assertDisabled($user);
$fake->assertStarted($user);
$fake->assertNothingRegenerated();

What the fake does

  • attempt() performs no TOTP maths — it returns the programmed outcome and records the user, the code and the result.
  • Like the real action, attempt() fails a user without confirmed 2FA whatever is programmed — no event, nothing spent — so a test can’t pass on something production would refuse. Enrol the user first with start() and confirm() under the fake, which accepts any code.
  • For an enrolled user, attempt() fires the real action’s events — TwoFactorVerified, RecoveryCodeConsumed, TwoFactorVerificationFailed and TwoFactorReplayDetected (with timestep 0) — and a recovery-code pass spends one stored code, so status() agrees with the result. A code typed with spaces or dashes counts as its digits, as in the real action.
  • start(), confirm(), recoveryCodes()->regenerate() and disable() still run the real actions, on the fake’s canned secret and recovery codes and its programmable verify(), and are recorded once they succeed — a failed confirm is not recorded.
  • status() and recoveryCodes()->remaining() read through to the model.

Programmable behaviour

MethodEffect
accept()Every verify() and attempt() passes via TOTP (the default) — attempt() still fails a user without confirmed 2FA.
acceptRecoveryCode()attempt() passes via a recovery code and spends one stored code (the first).
reject()Every verify() and attempt() fails.
replay()attempt() fails as a replay (replayed: true).
acceptCode($code)Accept only this exact code, via the current method.
withRemainingRecoveryCodes($n)Pin the reported count. Unset, the fake reports the user’s stored count, one lower when a recovery code passes. A stored code is spent either way.
withSecret($secret)The secret generateSecret() returns.
withRecoveryCodes(...$codes)The codes generateRecoveryCodes() returns.

Assertions

Every assertion on the returned fake. The $user argument of assertStarted(), assertConfirmed(), assertRegenerated() and assertDisabled() is optional:

AssertionPasses when
assertVerified()At least one attempt() passed.
assertVerifiedFor($user)An attempt() passed for this user.
assertVerifiedVia(TwoFactorMethod::RecoveryCode)An attempt() passed via this TwoFactorMethod.
assertVerificationFailed()At least one attempt() failed.
assertVerifyCount(2)Exactly this many attempt() calls were recorded.
assertCodeAttempted('424242')This code went through attempt() or the flat verify().
assertNothingVerified()No attempt() was recorded.
assertStarted($user)start() succeeded — for this user when one is passed.
assertNothingStarted()No enrolment was started.
assertConfirmed($user)confirm() succeeded — a failed confirm is not recorded.
assertNothingConfirmed()No enrolment was confirmed.
assertRegenerated($user)recoveryCodes()->regenerate() ran — for this user when one is passed.
assertNothingRegenerated()No recovery codes were regenerated.
assertDisabled($user)disable() ran — for this user when one is passed.
assertNothingDisabled()Two-factor was not disabled for anyone.

A full challenge test using the verification assertions:

use RoundlyConsulting\TwoFactor\Enums\TwoFactorMethod;
use RoundlyConsulting\TwoFactor\Facades\TwoFactor;

it('lets the user through with the right code only', function (): void {
    $user = User::factory()->create();

    // attempt() only passes a user with confirmed 2FA — enrol under a first fake:
    TwoFactor::fake();
    TwoFactor::for($user)->start();
    TwoFactor::for($user)->confirm('123456');

    $fake = TwoFactor::fake()->acceptCode('424242');

    $this->actingAs($user)->post('/login/2fa', ['code' => '000000'])->assertStatus(422);
    $this->actingAs($user)->post('/login/2fa', ['code' => '424242'])->assertOk();

    $fake->assertVerifyCount(2);
    $fake->assertCodeAttempted('000000');
    $fake->assertVerificationFailed();
    $fake->assertVerified();
    $fake->assertVerifiedFor($user);
    $fake->assertVerifiedVia(TwoFactorMethod::Totp);
    $fake->assertNothingStarted();
    $fake->assertNothingConfirmed();
    $fake->assertNothingDisabled();
});

Assertions throw TwoFactorAssertionFailedException — a package exception, not a PHPUnit assertion — so they work under any test runner. The fake is test-only: it is bound solely through TwoFactor::fake() and never in production.

Testing with real codes

To exercise the real crypto, freeze the clock with Carbon::setTestNow() and compute the current code with TwoFactor::currentCode():

use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\TwoFactor\Events\TwoFactorConfirmed;
use RoundlyConsulting\TwoFactor\Events\TwoFactorReplayDetected;
use RoundlyConsulting\TwoFactor\Facades\TwoFactor;

it('rejects the confirmation code replayed at first login', function (): void {
    Carbon::setTestNow(Carbon::createFromTimestamp(1_700_000_000));
    Event::fake();

    $user = User::factory()->create();
    $setup = TwoFactor::for($user)->start();
    $code = TwoFactor::currentCode($setup->secret);

    TwoFactor::for($user->fresh())->confirm($code);

    expect(TwoFactor::for($user->fresh())->status()->enabled)->toBeTrue();
    Event::assertDispatched(TwoFactorConfirmed::class);

    // The exact code used to confirm cannot double as the first login code.
    expect(TwoFactor::for($user->fresh())->attempt($code)->verified)->toBeFalse();
    Event::assertDispatched(TwoFactorReplayDetected::class);

    // A code at a later timestep still verifies.
    Carbon::setTestNow(Carbon::createFromTimestamp(1_700_000_060));
    expect(TwoFactor::for($user->fresh())->attempt(TwoFactor::currentCode($setup->secret))->verified)->toBeTrue();

    Carbon::setTestNow();
});

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.