# Migrate WordPress Users to Laravel Without a Password Reset

**Author:** Mozex | **Published:** 2026-10-02 | **Tags:** Laravel, PHP, WooCommerce | **URL:** https://mozex.dev/blog/30-migrate-wordpress-users-to-laravel-without-a-password-reset

---


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](#a-hasher-that-knows-wordpresss-formats). Laravel then replaces each user's hash with a Laravel bcrypt hash on their first login.

<!--more-->

## 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](https://wordpress.org/documentation/article/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](https://github.com/php/php-src/blob/PHP-8.4.0/UPGRADING#L749-L751) 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:

```sql
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:

```text
+--------+-------+
| 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:

```php
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`](https://github.com/laravel/framework/blob/v13.33.0/src/Illuminate/Hashing/BcryptHasher.php#L82-L93) checks the algorithm before it compares anything:

```php
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](https://laravel.com/docs/hashing#hash-algorithm-verification) 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](https://github.com/WordPress/WordPress/blob/7.1.2/wp-includes/pluggable.php#L2812-L2816)).

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
<?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()`](https://github.com/WordPress/WordPress/blob/7.1.2/wp-includes/pluggable.php#L2843-L2887). 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
<?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`:

```ini
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](https://laravel.com/docs/authentication#automatic-password-rehashing), `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](https://github.com/mikemclin/laravel-wp-password/issues/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](https://mozex.dev/blog/6-woocommerce-to-laravel-migration-the-parts-nobody-talks-about#password-hashes-dont-match). 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:

```php
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.