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.
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:
PasswordHasherFactory) — per-tenant or per-role algorithm tiers.MigratingPasswordHasher + needsRehash() to transparently rotate hashes on login, and MessageDigestPasswordHasher to keep verifying an MD5/SHA-1 legacy estate.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.
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.
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.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.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 ^.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.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.Hash::make() you pay the cost twice, and a slow argon2 hash inside a transaction holds a connection far longer than it should.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.How can I help you explore Laravel packages today?