Skip to content

Password hashing is a port, not an algorithm

Most PHP code treats password hashing as an algorithm choice. You write password_hash($password, PASSWORD_BCRYPT) directly in your authentication code and you move on. It works until the day the recommendation changes, and by then the algorithm you picked is hardcoded in every place that touches a password.

The problem

Password hashing recommendations are not stable. bcrypt was the safe default for years. Then Argon2id won the Password Hashing Competition and became the recommended choice for new applications. Another shift will come.

When your code calls password_hash($password, PASSWORD_BCRYPT) inline, the algorithm is a decision baked into your business logic. Migrating means finding every call site, changing it, and hoping you found them all. The cost of that migration is proportional to how scattered the calls are, and it grows every time you add a feature that touches a password.

The root cause is coupling. Your authentication code depends on a specific hashing algorithm when all it actually needs is something that can hash a password and verify it later.

The mechanism

Phexium treats hashing as a port. PasswordHasherInterface defines what authentication needs, and nothing more:

namespace Phexium\Plugin\PasswordHasher\Port;

interface PasswordHasherInterface
{
    public function hash(string $plainPassword): string;

    public function verify(string $plainPassword, string $hash): bool;
}

Two methods. No mention of bcrypt, Argon2, cost factors, or salts. Those are implementation details, and they live in the adapters.

BcryptPasswordHasher wraps PHP's bcrypt and exposes its cost as an optional constructor argument:

final readonly class BcryptPasswordHasher implements PasswordHasherInterface
{
    public function __construct(private ?int $cost = null) {}

    #[Override]
    public function hash(string $plainPassword): string
    {
        $options = [];

        if ($this->cost !== null) {
            $options['cost'] = $this->cost;
        }

        return password_hash($plainPassword, PASSWORD_BCRYPT, $options);
    }

    #[Override]
    public function verify(string $plainPassword, string $hash): bool
    {
        return password_verify($plainPassword, $hash);
    }
}

The two Argon2 variants share their logic through an abstract base. Only the algorithm constant differs:

final readonly class Argon2idPasswordHasher extends AbstractArgon2PasswordHasher implements PasswordHasherInterface
{
    #[Override]
    protected function algorithm(): string
    {
        return PASSWORD_ARGON2ID;
    }
}

A fourth adapter, PlaintextPasswordHasher, prefixes the password with $plain$ and does no real hashing. It exists for tests, where genuine hashing would burn CPU on every fixture.

The authentication handler depends on the interface and never names an algorithm:

final readonly class AuthenticateUserHandler implements CommandHandlerInterface
{
    public function __construct(
        private UserRepository $userRepository,
        private PasswordHasherInterface $passwordHasher,
        // clock, event bus, logger omitted
    ) {}

    public function __invoke(AuthenticateUserCommand $command): void
    {
        $user = $this->userRepository->findByEmail(Email::fromString($command->email));

        if (!$user instanceof User) {
            throw new InvalidArgumentException('Invalid credentials');
        }

        if (!$this->passwordHasher->verify($command->password, $user->getHashedPassword()->getValue())) {
            throw new InvalidArgumentException('Invalid credentials');
        }

        // dispatch UserAuthenticatedEvent ...
    }
}

Switching the whole application from bcrypt to Argon2id is one line in the DI container. The demo wires bcrypt in production:

// app/demo/config/container.php
PasswordHasherInterface::class => autowire(BcryptPasswordHasher::class),

Change that single binding and every hash produced from then on uses Argon2id:

PasswordHasherInterface::class => autowire(Argon2idPasswordHasher::class),

No handler changes, no repository changes. The authentication code never knew which algorithm it was using, so there is nothing in it to update. The test container makes the same move for a different reason, binding the plaintext adapter so the suite never pays for real hashing:

// app/demo/config/container_test.php
PasswordHasherInterface::class => autowire(PlaintextPasswordHasher::class),

Trade-offs

The swap is free, but it does not rehash anything for you. PHP's password_verify reads the algorithm out of the stored hash, so existing bcrypt hashes keep verifying after you switch the binding to Argon2id. The port has no needsRehash concept, though, and the adapters never upgrade an old hash. An existing user keeps their bcrypt hash until they change their password, unless you add rehash-on-login yourself. Gradual, mixed-algorithm coexistence is the default, and finishing the migration is your job.

Tuning is an adapter concern, not a port concern. The cost factors - bcrypt's cost, Argon2's memory_cost, time_cost, and threads - are constructor arguments on each adapter, not methods on the interface. Code that depends on the port cannot adjust them; tuning lives in container wiring. That boundary is deliberate, but it means there is no generic way to dial cost up or down through the port.

The plaintext adapter is a sharp edge. If it leaks into production through a misconfigured container, you store passwords in near-plaintext. The only thing preventing that is wiring discipline between your production and test containers.

Finally, every adapter that ships delegates to PHP's password_* family, so they cover only the algorithms PHP supports natively. That is not a limit of the port itself - you can implement the interface with libsodium or a managed service - but the bundled adapters share that assumption.

What this enables

You run fast tests and safe production from the same code. Tests bind plaintext, production binds bcrypt or Argon2id, and the handlers, the repository, and the domain stay identical across both.

You migrate algorithms as a configuration decision rather than a code change. When the next recommendation arrives, you reach for an adapter that already ships or write a new one, then flip a single line.

Adding a hasher is the Open/Closed Principle in practice. A new algorithm is a new class implementing two methods, and no existing code is touched, because nothing existing depends on the concrete hasher.

This is the same shape as swapping an ID generation strategy. The lesson repeats across the framework: depend on what the code needs - something that hashes, something that generates an ID - not on how that need is met. The authentication code staying ignorant of bcrypt is not an accident. That ignorance is what makes the swap free.