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;

// 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/enable')->assertOk();        // calls TwoFactor::for($user)->start()
$this->post('/two-factor/disable')->assertOk();       // or $user->disableTwoFactor()
$fake->assertStarted($user);
$fake->assertDisabled($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.
  • 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).
acceptRecoveryCode()attempt() passes as if a recovery code was spent.
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.
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();
    $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.