markup/fallback-password-encoder-bundle
Symfony bundle adding a fallback password encoder so users with legacy or unsupported hashes can still log in. It verifies old credentials and transparently rehashes them with your modern encoder after a successful login, easing migrations without forcing password resets.
Read the source before you install anything. This is a Symfony bundle (it registers a Bundle class, ships DI extension/compiler-pass config in YAML/XML, and implements Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface) — not a Laravel package. There is no service provider, no config/fallback_password.php to publish, and no auto-discovery. What you actually want is one class: the encoder stack that tries a list of encoders in order and returns on the first valid match.
First steps:
composer.json for the symfony/* and php constraints. It targets the PHP 5.x/7.x + Symfony 2.x/3.x era; on Laravel 10/11 (PHP 8.1+, Symfony 6/7 components) composer require markup/fallback-password-encoder-bundle will either fail resolution or drag in a symfony/security-core version you don't want.App\Auth\LegacyHasher with an ordered list of strategies, and a HasLegacyPassword trait on User.First use case: a users table with mixed hash formats — bcrypt/argon2 for accounts created since the migration, md5, sha1, or crypt() DES for pre-2015 accounts. Goal: legacy users log in with their old password, and the hash is transparently upgraded to Hash::make() so the legacy path eventually dies.
Minimal wiring if you do copy the class:
// App\Providers\AuthServiceProvider
$this->app->singleton(LegacyHasher::class, fn () => new LegacyHasher([
'bcrypt' => fn ($p, $h) => Hash::check($p, $h),
'md5' => fn ($p, $h) => hash_equals($h, md5($p)),
'sha1' => fn ($p, $h) => hash_equals($h, sha1($p)),
'crypt' => fn ($p, $h) => hash_equals($h, crypt($p, $h)),
]));
Discriminate by format, don't just try-each-in-turn. The bundle's "fallback stack" semantics are fine, but naive iteration means you run a bcrypt comparison against md5 rows on every legacy login (slow) and can produce type-confusion matches. Prefer a cheap discriminator first — str_starts_with($hash, '$2y$'), '$argon2id$', length 32 → md5, length 40 → sha1, no $ prefix → DES crypt — then run exactly one verification.
Verify-then-upgrade (lazy hash migration). The canonical pattern:
if ($hasher->check($plain, $user->password)) {
if ($hasher->needsRehash($user->password)) {
$user->forceFill(['password' => Hash::make($plain)])->save();
}
Auth::login($user);
}
Only lazy upgrade — you don't have plaintext for anything else, so a mass rehash is not possible without forcing resets.
Custom UserProvider is the cleanest Laravel integration. Override retrieveByCredentials() and validateCredentials() in a subclass of EloquentUserProvider, then register it in config/auth.php. This mirrors exactly what the bundle does inside Symfony's DaoAuthenticationProvider and keeps the upgrade side effect out of the model and out of booted().
Fortify / Breeze / custom guard. Use Fortify::authenticateUsing(fn ($request) => ...) or a custom guard driver. Do the verify + rehash there, and return the User only after the rehash commits.
Auth::attempt is not enough. It routes through Hash::check, which only understands PHP's password_* algorithms. Any legacy path needs a custom provider or a hook that replaces the credential check.
Config-driven strategy list. Keep it in config/auth.php or a dedicated config/legacy_hashers.php:
'legacy_hashers' => env('LEGACY_HASHERS', 'md5,sha1,crypt'),
Then filter the strategy list per tenant/environment. Turning a strategy off in prod is how you retire md5 once the fallback metric hits zero.
Multi-tenant coexistence. Scoped container binding per tenant (or a resolver keyed off tenant()->id) lets one tenant keep sha1 support while another is bcrypt-only, without branching inside the hasher.
MigratingPasswordHasher / PasswordHasherInterface. Symfony ships this concept natively now; Laravel ships nothing comparable in HashManager. Budget ~an hour to reimplement the 40 lines rather than a day fighting a composer require that resolves symfony/security-core:^3.0 next to Laravel 11.Hash::check against legacy rows. Bcrypt truncates at 72 bytes and its 60-char output can be fed into an md5 comparison if you forget the format guard — you can get surprising trues. Always validate the hash shape before comparing.crypt() DES is a special case. The output has no $ prefix and is salted with its first two characters, so password_verify() will always return false. You need hash_equals($stored, crypt($plain, $stored)).hash_equals for every non-password_* comparison.'', NULL, or a literal '!' (the classic "locked account" sentinel). Short-circuit these to a hard fail before any strategy runs, or a blank submission can authenticate.AuthenticateSession, changing the password column invalidates the user's session — the user logs in and is immediately kicked out. Either exclude password from the invalidation check, or rehash after the session is established.LegacyHasher contract with an injectable strategy array (or a LegacyHasher::extend() call), so adding a new format is a config/binding change, not a class edit.How can I help you explore Laravel packages today?