infinitypaul/laravel-password-history-validation
Laravel validation rule that stops users from reusing old passwords. It stores password history, checks new passwords against previous hashes, and offers configurable limits so you can drop it into any form request or validator with minimal setup.
Install the package:
composer require infinitypaul/laravel-password-history-validation
Laravel's package discovery registers the service provider automatically; no manual config/app.php edit is required on Laravel 5.5+.
Create the history table. The package ships a migration that is loaded automatically, so a plain php artisan migrate is usually enough. As of 2.0.2 the migration is also publishable, which is the preferred path if you need to change the column type (e.g. UUID keys):
php artisan vendor:publish --provider="Infinitypaul\PasswordHistory\PasswordHistoryServiceProvider"
# or list available tags first:
php artisan vendor:publish
php artisan migrate
Attach the rule to your password validation. The first, highest-value use case is registration plus password reset — the two places a user picks a new password:
// app/Actions/Fortify/CreateNewUser.php, RegisterController, or a FormRequest
'password' => [
'required',
'confirmed',
Password::defaults(),
new PasswordHistory(), // or the rule's static helper, see below
],
Verify history is actually being written. After a registration or password change, check the password_histories table (one row per stored hash). If it's empty, nothing is enforcing anything — that's the first thing to look at when the rule appears to "never fire".
Where to look first in the source: the rule class (how it resolves the user and how many entries it compares), the service provider (migration loading / publish tags / config merge), and any event listeners that persist a hash after a successful password change. Those three files explain ~90% of the behaviour.
Centralise the rule via Password::defaults(). Rather than sprinkling the rule across controllers, register it once so every Password::defaults() call site inherits it:
// AppServiceProvider::boot()
Password::defaults(function () {
return Password::min(8)
->letters()->numbers()
->uncompromised()
->rules([new PasswordHistory()]);
});
This guarantees new rules apply to Fortify, Breeze, Jetstream, and any custom reset flow consistently.
FormRequest pattern. For non-Fortify flows, keep it declarative:
public function rules(): array
{
return [
'password' => ['required', 'confirmed', 'min:8', new PasswordHistory()],
];
}
The validation message surfaces as a normal 422 with the password key, so Vue/Inertia/Livewire error bags work without extra work.
Recording history explicitly. If your app changes passwords outside the package's watched events (admin panels, importers, User::update(['password' => ...]), seeders, Artisan commands), no history row is written and the rule silently has nothing to compare. Either route those paths through the package's helper/trait method or add an observer:
User::updated(function (User $user) {
if ($user->wasChanged('password')) {
// persist via the package's history model/helper
}
});
UUID / string primary keys (new in 2.0.2). For HasUuids, HasUlids, or custom string keys, the generated migration now adapts the foreign/user_id column type instead of assuming bigint. This only helps you if the migration is generated/published by 2.0.2+ — see Gotchas.
Publish-and-customise migration. Publishing is the intended extension point for: changing user_id to uuid/ulid, adding a created_at index for pruning, adding a guard/broker column for multi-auth apps, or swapping the table name. Once published, the package stops shadowing it, so keep the file in database/migrations and treat it as your own.
Per-user rule instance. The rule needs a subject. On registration there is no authenticated user yet, so either the rule no-ops gracefully or you construct it with the pending user. On reset/change flows, pass the resolved user explicitly — relying on auth()->user() inside the rule is the most common reason it "works on profile page but not on reset".
Multi-guard / multi-model apps. Keep separate history per model and apply the rule per-guard. If you use one table for admins and users, add a discriminating column during the migration publish step rather than filtering by ID collisions.
Retention. History grows one row per password change, forever. Add a scheduled prune (keep last N per user, or delete rows older than X) — otherwise the table outgrows the value it provides.
user_id column by hand ($table->uuid('user_id') / foreignUuid) or write a follow-up migration. Symptom: SQL errors inserting a UUID string into a bigint column, or silent truncation/zero values.migrate:fresh; in production, never run both.update() bypass, or event listener not registered because the provider wasn't discovered in a non-standard bootstrap).confirmed and min before the history rule so users get the obvious error first; history checks against hashes are the expensive part. Don't short-circuit with bail in a way that hides history failures you actually want surfaced.Hash::check, so old rows keep validating after you raise bcrypt rounds/argon settings. That's fine — but re-hashing on login doesn't rewrite history rows, so don't assume history reflects current cost.RefreshDatabase and factories, history rows leak between tests if you reuse users; use fresh factories per test and Hash::fake-style drivers where the hash algorithm makes assertions brittle. Assert on the validation message key, not the literal string, since wording can change.password_confirmation-less field or an admin "set user password" screen without passing the target user produces confusing behaviour.php artisan config:clear after changing retention/count values; cached config is a frequent reason limit changes appear ignored in production.Password::defaults(), the published migration, and the package's history model/helper. Avoid subclassing internals or depending on the exact table/column names in application code — 2.0.x has already changed schema generation once.composer update, run the test suite, and only then decide whether to re-publish the migration. Pinning ^2.0.2 and forgetting to re-publish is the easy way to think you have UUID support when you don't.How can I help you explore Laravel packages today?