Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Fallback Password Encoder Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Skim 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.
  2. Read the stack class (~40–80 lines) and the compiler pass that collects tagged encoders. That's the whole idea.
  3. Realistically: copy the pattern, not the package. Implement 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)),
]));

Implementation Patterns

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.

Gotchas and Tips

  • It is abandoned and it is Symfony. 6 stars, 0 dependents, and last activity is long before Symfony 5.3's 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.
  • Don't trust 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)).
  • Timing leaks. Try-each-fallback exposes which algorithm a user is on via response latency. Discriminate by format (constant work) and use hash_equals for every non-password_* comparison.
  • Empty / locked passwords. Legacy rows often carry '', 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.
  • Rehash-on-login vs. session invalidation. If you use Laravel's 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.
  • Encoder collection order is not config order. The bundle collects encoders through a tagged compiler pass, so priority — not your YAML ordering — decides the sequence. Get it wrong and every legacy login runs bcrypt first (slow). If you port it, make the order an explicit array.
  • Extension point is the strategy list, not inheritance. In the Symfony original you'd tag a service to add a strategy; in a Laravel port, register a 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.
  • Test with real fixtures. Seed users with actual hashes of known passwords for each algorithm, then assert: (a) legacy login works, (b) the hash changes exactly once, (c) the second login takes the modern path, (d) a wrong password never triggers a rehash.
  • Instrument the fallback. Log or emit a metric every time a legacy strategy matches. That counter going to zero is your signal to delete the whole code path — treat md5/sha1 support as deliberate, tracked security debt.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core