smarttechtank/larastarter
Larastarter by SmartTechTank is a Laravel starter kit with a ready-made admin foundation: authentication, roles and permissions, user management, CRUD scaffolding, and a clean UI to help you launch new PHP apps faster.
This release adds a first-class disable 2FA flow. The fastest way to understand the package's conventions is to read the two classes it just shipped: TwoFactorAuthController (the disable method) and TwoFactorDisableRequest (the validation contract). The published TwoFactorAuthDisableTest doubles as executable documentation for the expected request shape.
Minimal setup:
composer require <vendor>/<package>
php artisan vendor:publish --provider="<Vendor>\<Package>\<PackageServiceProvider>"
php artisan migrate
Then wire the package into your authenticatable model:
use <Vendor>\<Package>\Concerns\HasTwoFactorAuth;
class User extends Authenticatable
{
use HasTwoFactorAuth;
}
First use case to get working end-to-end: enable → confirm → log in with a code → disable with reauthentication. If that loop works in a feature test, the rest of the package is mostly configuration.
Where to look first, in order:
config/<package>.php — issuer name, code window/drift, recovery code count, route middleware.routes/ — the enable/confirm/disable/challenge endpoints and their middleware stack.TwoFactorDisableRequest — the exact fields the disable endpoint accepts.Enabling is a two-phase flow. Most packages (and this one, judging by the controller shape) separate generating a secret from activating it. Don't mark 2FA as enabled on the user until the user proves possession of the device:
// Phase 1: issue secret + QR payload, keep it in session/pending state
// Phase 2: verify the submitted code, then persist and flip the enabled flag
Gate the login challenge outside your login controller. Rather than branching inside LoginController, hook the challenge as a middleware or a Login event listener that redirects users with 2FA enabled to the challenge route and marks the session as "2FA passed" only after a valid code. This keeps Fortify/Breeze/Jetstream integration from fighting your custom logic.
Disabling now requires reauthentication — lean on that. The new controller method accepts a password or a verification code. Route it behind your password-confirmation middleware and treat it as a sensitive action:
Route::delete('/user/two-factor-authentication', [TwoFactorAuthController::class, 'disable'])
->middleware(['auth', 'password.confirm', 'throttle:6,1']);
Because TwoFactorDisableRequest is a dedicated FormRequest, you have a clean seam for customization: override authorize() to restrict self-disable (e.g. block users in an enforced-2FA group), and override rules() to require a password instead of accepting a code, or vice versa.
Post-disable cleanup is your responsibility. In the same transaction as clearing the secret, regenerate recovery codes, forget any "2FA verified" session flag, and fire an audit event:
DB::transaction(function () use ($user) {
$user->forceFill(['two_factor_secret' => null, 'two_factor_confirmed_at' => null])->save();
$user->recoveryCodes()->delete();
session()->forget('2fa.passed');
event(new TwoFactorDisabled($user));
});
Enforce 2FA selectively with middleware, not with a global check in the controller. A small RequireTwoFactor middleware that bounces unenrolled users to the setup screen is far easier to reason about than scattering conditionals, and it composes with role/permission checks.
Test with the shipped test as a template. Mirror TwoFactorAuthDisableTest: actingAs($user), satisfy the password-confirmation window (or post('/password/confirm') first), assert the secret is nulled and recovery codes are gone. Use a fake TOTP generator or the package's code helper rather than asserting on hardcoded codes.
Integration tips: if the app already uses Fortify's own 2FA columns, pick one source of truth before enabling this package — dual traits on the same model is the most common source of "2FA randomly stops working" bugs.
password.confirm stores a timestamp governed by auth.password_timeout; a test that confirms a password and then travels through several requests can fail once the window lapses. Set the timeout explicitly in the test's config.DELETE/POST with CSRF, and throttle it. A disable endpoint that accepts a code is a second authentication surface and deserves the same rate limiting as login.--force selectively — don't blanket-force config or migrations you've already edited. Verify TwoFactorDisableRequest actually exists in your app namespace after upgrading, otherwise the injected controller method will 500.hash_equals) if you add custom verification paths — a naive === on a 6-digit code is a timing oracle.session()->regenerate() after login is the usual culprit.How can I help you explore Laravel packages today?