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

Password Hasher Laravel Package

symfony/password-hasher

Symfony's PasswordHasher component provides secure password hashing utilities, with a factory to configure multiple algorithms like bcrypt and sodium. Hash plain passwords and verify them easily in your PHP applications.

View on GitHub
Deep Wiki
Context7

Getting Started

First, correct the framing before you write any code: symfony/password-hasher is a Symfony subtree split, not a Laravel package. Laravel's Illuminate\Hashing already wraps password_hash()/password_verify() and covers bcrypt/argon2id fine. You reach for this component only when you need something Laravel's Hash facade does not give you:

  • Multiple named hashers under one API (PasswordHasherFactory) — per-tenant or per-role algorithm tiers.
  • A real migration engine — MigratingPasswordHasher + needsRehash() to transparently rotate hashes on login, and MessageDigestPasswordHasher to keep verifying an MD5/SHA-1 legacy estate.
  • A pluggable PasswordHasherInterface (hash / verify / needsRehash) so your hashing policy is an object you can unit-test and swap.

If the requirement is just "bcrypt with a sane cost factor", close this and tune config/hashing.php. It'll save you a dependency and a week of rollout.

composer require symfony/password-hasher

Where to look first: the PasswordHasherFactory source (it's how you discover the exact config keys per algorithm — cost, time_cost, memory_cost, algorithm, iterations, encode_as_base64), then PasswordHasherInterface, then MigratingPasswordHasher.

First use case — bind it as a custom Laravel hash driver so every existing Hash::make() / Hash::check() call (Breeze, Fortify, Jetstream, Passport's password grant, EloquentUserProvider::validateCredentials) routes through it without touching a single call site:

// app/Hashing/SymfonyHasher.php
namespace App\Hashing;

use Illuminate\Contracts\Hashing\Hasher;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;

final class SymfonyHasher implements Hasher
{
    public function __construct(private readonly PasswordHasherInterface $hasher) {}

    public function make(#[\SensitiveParameter] $value, array $options = []): string
    {
        return $this->hasher->hash($value);
    }

    public function check(#[\SensitiveParameter] $value, $hashedValue, array $options = []): bool
    {
        if (! is_string($hashedValue) || $hashedValue === '') {
            return false;
        }

        return $this->hasher->verify($hashedValue, $value);
    }

    public function needsRehash($hashedValue, array $options = []): bool
    {
        try {
            return $this->hasher->needsRehash($hashedValue);
        } catch (\Throwable) {
            return true; // unknown format → rehash on next successful login
        }
    }

    public function info($hashedValue): array
    {
        return password_get_info((string) $hashedValue);
    }
}
// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Hash::extend('symfony', function ($app) {
        $config = config('hashing.symfony');

        return new SymfonyHasher(
            (new PasswordHasherFactory($config['hashers']))
                ->getPasswordHasher($config['default'])
        );
    });
}
// config/hashing.php
'driver' => env('HASH_DRIVER', 'symfony'),

'symfony' => [
    'default' => 'default',
    'hashers' => [
        'default' => [
            'algorithm'   => 'sodium',           // argon2id via libsodium
            'time_cost'   => (int) env('HASH_TIME_COST', 4),
            'memory_cost' => (int) env('HASH_MEMORY_COST', 65536),
        ],
        'burst' => [
            'algorithm' => 'bcrypt',
            'cost'      => (int) env('HASH_BCRYPT_COST', 12),
        ],
    ],
],

Rollback is one config line: set HASH_DRIVER=bcrypt. That property alone justifies the adapter.

Implementation Patterns

Named hashers as a policy map. Stop calling Hash::driver() from controllers. Wrap selection in one service so the mapping from who to which algorithm lives in a single, testable place:

final class PasswordHasherManager
{
    /** @param array<string, PasswordHasherInterface> $hashers */
    public function __construct(
        private readonly array $hashers,
        private readonly string $default,
    ) {}

    public function forUser(?object $user): PasswordHasherInterface
    {
        $tier = $user->tenant_hash_tier ?? 'default';

        return $this->hashers[$tier] ?? $this->hashers[$this->default];
    }
}

Note the factory also accepts class-name keys (['App\Models\Admin' => [...]]) and getPasswordHasher($object) resolves by get_class(). That's the Symfony idiom; it's fine for a handful of user models, but a tenant-tier string map is usually the better fit in a multi-tenant Laravel app.

Rehash-on-login. The Login event does not carry the plaintext password, so you cannot rehash there alone. Capture the credential on the Validated event, consume it on Login, and fail soft:

final class RehashPasswordOnLogin
{
    public function __construct(private PasswordHasherManager $hashers) {}

    public function onValidated(Validated $event): void
    {
        // scoped() binding so this cannot leak across requests under Octane
        app(PlainPasswordCapture::class)->put($event->credentials['password'] ?? null);
    }

    public function onLogin(Login $event): void
    {
        $plain = app(PlainPasswordCapture::class)->pull();

        if ($plain === null || ! Hash::needsRehash($event->user->getAuthPassword())) {
            return;
        }

        try {
            $event->user->forceFill([
                'password' => Hash::make($plain),
                'password_hashed_at' => now(),
            ])->saveQuietly();
        } catch (\Throwable $e) {
            report($e); // never fail a successful login because a rehash write failed
        }
    }
}

Register both listeners in EventServiceProvider. Add an audit log line here — the package emits no events, and SOC2/ISO auditors will ask which algorithm a given hash was created with. Store password_algo alongside the hash; it costs one column and makes the later "are we done migrating?" query a single GROUP BY.

Legacy bridge. The whole migration story is verify-old → rehash-on-success → retire-when-quiet. MigratingPasswordHasher implements exactly that:

$migrating = new MigratingPasswordHasher(
    $factory->getPasswordHasher('default'),   // target
    [                                          // legacy, tried in order
        new MessageDigestPasswordHasher('md5', false, 1),
        new MessageDigestPasswordHasher('sha1', true, 1),
        new BcryptPasswordHasher(10),          // hashes made by the old Laravel config
    ],
);

verify() succeeds if any encoder matches; needsRehash() then returns true because the best hasher didn't match — which is precisely the flag your login listener checks. No bulk rehash is possible (you don't have plaintext), so the schedule is: new registrations and password resets on the target policy immediately; login-driven upgrades from day one; monitor the legacy-hit count to zero over a 60–90 day window before deleting the old encoders.

Testing. Never assert on hash strings (salts are random). Assert behaviour:

it('still verifies a legacy md5 row', function () {
    $user = User::factory()->create(['password' => md5('secret')]);

    expect(Hash::check('secret', $user->password))->toBeTrue();
});

it('upgrades the hash on login', function () {
    $user = User::factory()->create(['password' => md5('secret')]);

    $this->post('/login', ['email' => $user->email, 'password' => 'secret']);

    expect($user->fresh()->password)->toStartWith('$argon2id$')
        ->and(Hash::check('secret', $user->fresh()->password))->toBeTrue();
});

Keep one frozen fixture hash per legacy format in tests/Fixtures/ so a future encoder swap can't silently break the bridge.

Cost tuning is a measurement, not a guess. Benchmark on production-class hardware, then set HASH_* per environment from that number, and add a health check that asserts extension_loaded('sodium') so a container missing ext-sodium fails loudly at boot rather than falling back to something weaker.

Gotchas and Tips

  • Argument order is reversed. Laravel is Hash::check($plain, $hash); Symfony is $hasher->verify($hash, $plain). This is the single most likely bug in the adapter, and it's silent — a swapped call just returns false. Write the test first.
  • Bcrypt's 72-byte limit is enforced, not truncated. Symfony's BcryptPasswordHasher throws on inputs over 72 bytes; PHP's native bcrypt silently truncates. Switching a bcrypt app to this hasher can start rejecting long passphrases that previously "worked". Either validate max:72 in the form request or move to sodium/argon2id.
  • needsRehash() can throw on foreign formats. Wrap it in try/catch and default to true. A needless rehash on next login is harmless; a 500 on the login path is not.
  • Changing hashers triggers a rehash stampede. Flip from bcrypt:10 to bcrypt:12 and every needsRehash() returns true — thousands of expensive writes in the first hour. Roll out behind a percentage flag, or rate-limit rehash writes per minute, and watch DB write latency.
  • v8.x requires PHP >= 8.4. A fleet on 8.1/8.2 must pin the 6.4 or 7.x line. Mixed minors across services is a real upgrade treadmill — pin explicitly in composer.json rather than relying on ^.
  • Ignore the metadata. "807 stars / 0 dependents" is a subtree-split artifact; adoption lives in symfony/security-bundle. Don't let SCA tooling read that as abandonment, and don't read it as independent popularity.
  • Laravel Hasher::needsRehash($hash, $options) ignores $options in this adapter. Native drivers compare cost against the passed options; yours compares against container config. If any code calls Hash::needsRehash($hash, ['cost' => 14]), it will not do what it looks like it does.
  • Don't reach for SignatureHasher for passwords. It's for signed payloads / expiring links (computeSignatureHash() — v8.1 added enum support). For API tokens you need to look up, use a deterministic HMAC (hash_hmac('sha256', $token, $key)), never a password hasher — you can't index a salted hash.
  • Do the hashing before opening a transaction. If a wrapped retry re-runs Hash::make() you pay the cost twice, and a slow argon2 hash inside a transaction holds a connection far longer than it should.
  • Extension points: implement PasswordHasherInterface for a proprietary or exotic legacy format (three methods — trivial), or PasswordHasherFactoryInterface if you want hashers resolved from the container rather than an array. UserPasswordHasher and the security:hash-password command live in symfony/security-http / security-bundle, not here — in standalone Laravel, generate and eyeball hashes with php artisan tinker through your own driver.
  • eraseCredentials() is deprecated in 7.3 and removed in 8.0. Irrelevant in a Laravel app, but relevant if you share a codebase with a Symfony service and are aligning versions.
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.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib