Skip to content

Migrate WordPress Users to Laravel Without a Password Reset

· 10 min read ·

I built a WordPress site in Docker to see what a wp_users table holds after WordPress's move to bcrypt: WordPress 6.7.9 first, then an upgrade to 7.1.2, with users created on both versions. Then I copied its eight users, hashes intact, into a fresh Laravel 13.33 app on PHP 8.5 and tried to log each one in.

Seven of the eight logins threw RuntimeException: This password does not use the Bcrypt algorithm. The right password threw it, and so did a wrong one.

WordPress has hashed passwords with bcrypt since 6.8, and Laravel uses bcrypt too, so this looks like it should just work. It fails for two separate reasons, and one of them never shows an error. If you only need the fix, it's two steps: import the rows with the query builder, then register a hasher that knows WordPress's formats. Laravel then replaces each user's hash with a Laravel bcrypt hash on their first login.

What's in a migrated wp_users table?

My test table ended up holding four formats, and any site that ran WordPress before 6.8 can hold more than one:

user_pass starts with Written by Length
$P$B WordPress before 6.8 (phpass) 34
$wp$2y$10$ or $wp$2y$12$ WordPress 6.8 and later 63
32 hex characters an MD5 set by hand in MySQL 32
$2y$ the archived Roots wp-password-bcrypt plugin 60

The upgrade doesn't touch old hashes. WordPress rewrites a $P$ hash when that user next logs in (wp_authenticate_username_password() calls wp_password_needs_rehash(), then wp_set_password()). Of the three users I created on 6.7.9, the one who logged in after the upgrade got a $wp$ hash, and the two who didn't kept $P$.

I made the MD5 row the way WordPress's own Reset your password article tells admins to: SET user_pass = MD5('(new-password)'). WordPress 7.1.2 still accepted it. I wrote the bare $2y$ row with plain password_hash() on PHP 8.3, which is what the archived Roots plugin's wp_hash_password() returned.

The number after $2y$ is the bcrypt cost, and by default WordPress doesn't pick it. It passes empty options to password_hash(), so PHP's default applies, and PHP 8.4 raised that default from 10 to 12. WP-CLI on PHP 8.3.33 wrote $wp$2y$10$; the same command on PHP 8.4.25 wrote $wp$2y$12$.

WooCommerce customers live in the same table: wc_create_new_customer() calls wp_insert_user() with the customer role, so everything below covers a store's customers too.

Count your formats before writing any code. This is the query I ran against the test site:

SELECT CASE
    WHEN user_pass LIKE '$wp%' THEN 'wp'
    WHEN user_pass LIKE '$P$%' THEN 'phpass'
    WHEN LENGTH(user_pass) = 32 THEN 'md5'
    ELSE 'other'
  END AS format,
  COUNT(*) AS users
FROM wp_users
GROUP BY format
ORDER BY format;

On my eight users it printed:

+--------+-------+
| format | users |
+--------+-------+
| md5    |     1 |
| other  |     1 |
| phpass |     2 |
| wp     |     4 |
+--------+-------+

The bare $2y$ hash lands in other, which is worth reading row by row on a real site.

Why does User::create() re-hash the WordPress hashes?

The obvious import is a loop over wp_users that calls User::create(). On the Laravel 13 skeleton, that ruins every $wp$, $P$ and MD5 hash. The User model casts password to hashed, and the cast keeps a value only when Hash::isHashed() says it's already a hash. That method checks whether password_get_info() recognises the value's algorithm. PHP doesn't recognise $wp$2y$..., $P$... or an MD5 string, so the cast treats each one as a plain password and bcrypts the hash string itself.

When I imported the same rows with User::create(), seven of the eight stored passwords came out as new $2y$12$ strings. Nothing threw. Every one of those users then failed Auth::attempt() with a plain false, with the stock hasher and with the one below, because the column now holds a hash of a hash. Only the bare $2y$ row survived, because PHP knows that format.

Write the rows with the query builder instead, which never touches model casts. With a wordpress connection pointing at the old database:

DB::connection('wordpress')->table('wp_users')->orderBy('ID')->each(function ($row) {
    DB::table('users')->insert([
        'name' => $row->display_name,
        'email' => $row->user_email,
        'password' => $row->user_pass, // copied as is; the hashed cast would hash it again
        'created_at' => $row->user_registered,
        'updated_at' => now(),
    ]);
});

All eight hashes arrived unchanged.

Why does Hash::check() throw on a WordPress hash?

With the hashes intact, Auth::attempt() asks the user provider, the provider calls Hash::check(), and Laravel's BcryptHasher checks the algorithm before it compares anything:

if ($this->verifyAlgorithm && ! $this->isUsingCorrectAlgorithm($hashedValue)) {
    throw new RuntimeException('This password does not use the Bcrypt algorithm.');
}

verifyAlgorithm comes from hashing.bcrypt.verify, which the framework's config sets to env('HASH_VERIFY', true), and isUsingCorrectAlgorithm() asks password_get_info() again. So the exception fires before the password matters, which is why a wrong password throws exactly like a right one. The Laravel docs call this deliberate: a hash from another algorithm "can be an indication of a malicious attack".

Stripping the prefix looks like the quick fix. substr($hash, 3) is a valid bcrypt hash, so nothing throws, but checking the password against it returns false for all four $wp$ users. What WordPress bcrypted is base64_encode(hash_hmac('sha384', $password, 'wp-sha384', true)), a 64-character string. That way a password longer than the 72 bytes bcrypt reads isn't cut short (wp_hash_password() in 7.1.2).

The docs' answer for mixed algorithms is HASH_VERIFY=false. With it, every $P$ and $wp$ check returned false instead of throwing, and the algorithm check is gone for every hash in the app.

A hasher that knows WordPress's formats

The fix is a hasher that checks the formats wp_check_password() checks and hands everything else to Laravel's bcrypt code, verification included. Save it as app/Hashing/WordPressHasher.php:

<?php

namespace App\Hashing;

use Illuminate\Hashing\BcryptHasher;

class WordPressHasher extends BcryptHasher
{
    public function check(#[\SensitiveParameter] $value, $hashedValue, array $options = []): bool
    {
        if (! $this->isWordPressHash($hashedValue)) {
            return parent::check($value, $hashedValue, $options);
        }

        $password = trim($value); // WordPress trims before hashing and at login

        return match (true) {
            str_starts_with($hashedValue, '$wp') => password_verify( // 6.8 and later
                base64_encode(hash_hmac('sha384', $password, 'wp-sha384', true)),
                substr($hashedValue, 3),
            ),
            str_starts_with($hashedValue, '$P$') => $this->phpass()->CheckPassword($password, $hashedValue),
            default => hash_equals($hashedValue, md5($password)), // an MD5 set by hand
        };
    }

    public function needsRehash($hashedValue, array $options = []): bool
    {
        return $this->isWordPressHash($hashedValue) || parent::needsRehash($hashedValue, $options);
    }

    private function isWordPressHash($hash): bool
    {
        return is_string($hash) && (str_starts_with($hash, '$wp') || str_starts_with($hash, '$P$')
            || preg_match('/^[0-9a-f]{32}$/', $hash) === 1);
    }

    private function phpass(): \PasswordHash
    {
        require_once resource_path('wordpress/class-phpass.php'); // copied from WordPress

        return new \PasswordHash(8, true);
    }
}

Each branch of check() mirrors one in wp_check_password(). Anything that isn't a WordPress hash goes to the parent, which still throws on a foreign algorithm, and needsRehash() answers true for every WordPress format.

For the $P$ branch, copy wp-includes/class-phpass.php from the WordPress install to resources/wordpress/. It's the class WordPress itself uses for those hashes, it's public domain, and new PasswordHash(8, true) is exactly how wp_check_password() calls it. Under app/ it would work too, but Composer's PSR-4 check warns about it on every optimized autoload dump.

Register the driver in app/Providers/AppServiceProvider.php:

<?php

namespace App\Providers;

use App\Hashing\WordPressHasher;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Hash::extend('wordpress', fn ($app) => new WordPressHasher($app['config']->get('hashing.bcrypt', [])));
    }
}

Then switch to it in .env:

HASH_DRIVER=wordpress

The framework's hashing config already reads HASH_DRIVER, so nothing has to be published. New passwords still come out as ordinary bcrypt at your BCRYPT_ROUNDS, because make() is inherited unchanged.

Laravel does the rehash for you

I ran the same eight logins with the hasher in place. All eight returned true. A wrong password first returned false and left the stored hash alone. And after each right password, the stored hash had changed: both $P$ hashes, all four $wp$ hashes, the MD5 and the bare $2y$10$ all became $2y$12$. The second login went through Laravel's ordinary bcrypt path.

I didn't write a listener for that. Since Laravel 11, SessionGuard::attempt() calls the provider's rehashPasswordIfRequired() after a successful check, and that saves a fresh Hash::make() of the typed password whenever Hash::needsRehash() says so. The hasher says so for every WordPress format; the bare $2y$10$ hash is caught by the parent's cost check against BCRYPT_ROUNDS=12.

Two things skip that step. One is 'rehash_on_login' => false in a published config/hashing.php. The other is a login that checks the password itself, like an API endpoint that runs Hash::check() and then issues a token or calls Auth::login(). There, call Hash::needsRehash() yourself and save a new hash when it returns true.

Because the hasher sits behind the Hash facade, code that checks a password without attempt() works too. The current_password validation rule calls the hasher directly. In a separate run I logged a $wp$ user in with Auth::loginUsingId(), which skips the rehash. The stock hasher made the rule throw the same RuntimeException; with this hasher it returned true for the right password and false for a wrong one.

One thing changes: surrounding spaces

WordPress trims the password before hashing it and trims the typed password at login (wp_authenticate()), so " dan-pass-71 " and "dan-pass-71" open the same account there. The hasher trims for $wp$, $P$ and MD5 hashes too, so both work until the first Laravel login. After the rehash, Laravel compares the exact string. My test user logged in with "dan-pass-71" first, and from then on the version with spaces failed.

I'd leave that alone. It only affects someone who types extra spaces around their password on one login and not on the next.

Why not mikemclin/laravel-wp-password, or my earlier post?

mikemclin/laravel-wp-password has 3,684,345 installs on Packagist (23 September 2026) and no push since September 2021. Its check() has no branch for $wp$ hashes, and issue #18, "No longer compatible with WordPress 6.8+", has been open since June 2025.

I also published a version of this fix in March, in the password section of my WooCommerce migration post. Its validateCredentials() calls Hash::check() first. On Laravel 13's defaults that line throws for every $wp$, $P$ and MD5 hash before the WordPress branches run, and its $P$ branch builds the package's WpPassword class without the constructor argument it requires. Use the hasher above instead.

When can the hasher go?

When no row in users still holds a WordPress hash. This counts them:

DB::table('users')
    ->where(fn ($query) => $query
        ->where('password', 'like', '$wp%')
        ->orWhere('password', 'like', '$P$%')
        ->orWhereRaw('LENGTH(password) = 32'))
    ->count();

On the test app it went from 7 to 0 as the eight users logged in. On a real site, anyone who never logs in again keeps their old hash, so expect the number to level off above zero. When it does, I'd send the rest a password reset link. Once the count reaches zero, remove WordPressHasher, its Hash::extend() line and HASH_DRIVER=wordpress together; with a WordPress hash still in the table, the stock hasher throws on that user's login again.

If your wp_users table has a format this hasher gets wrong, tell me what it starts with and I'll add it here.

Common questions

Why does Laravel throw "This password does not use the Bcrypt algorithm" for WordPress users?
Laravel's bcrypt hasher checks the stored hash's algorithm before it compares anything, and PHP doesn't recognise WordPress's $wp$, $P$ or MD5 formats. So Hash::check() throws a RuntimeException for the right password and for a wrong one. A hasher that handles those formats and passes everything else to Laravel's bcrypt code stops it.
Can I strip the $wp prefix and check the rest with password_verify()?
Only after the same HMAC step WordPress uses. WordPress 6.8 and later hash base64_encode(hash_hmac('sha384', $password, 'wp-sha384', true)) with bcrypt, so password_verify() on the stripped hash returns false for the plain password and true for that 64-character string.
Do WordPress users have to reset their passwords after moving to Laravel?
They don't, as long as they log in while the hasher is installed. With a hasher that verifies the WordPress formats and says they need a rehash, Laravel 11 and later replace each user's hash with Laravel's own bcrypt hash on their first successful Auth::attempt().
Why can't WordPress users log in after an import with User::create()?
The Laravel skeleton's User model casts password to hashed, and the cast only keeps values PHP recognises as password hashes. $wp$, $P$ and MD5 strings aren't, so the cast bcrypts the hash string itself. Insert the rows with the query builder so they're stored unchanged.

Share this post

Share on X
Share on LinkedIn
Share on Reddit
Share on Hacker News
Copy link

Stay in the Loop

Get the latest posts delivered to your inbox - on your schedule.

No spam. Unsubscribe anytime.

Scroll to top