Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Password History Validation Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

  1. 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+.

  2. 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
    
  3. 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
    ],
    
  4. 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.

Implementation Patterns

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.

Gotchas and Tips

  • 2.0.1 → 2.0.2 is a safe, additive upgrade. No breaking changes: the release adds UUID support and publishable migrations, plus StyleCI formatting only. Nothing to migrate unless you want the new migration behaviour.
  • Published migrations don't get the UUID fix retroactively. If you published (or ran) the migration before 2.0.2 on a UUID-keyed model, re-publishing won't alter the existing table. Edit the 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.
  • "Table already exists" after publishing. Publishing places a fresh migration while the package migration has already run under the same/duplicate name. Delete the redundant file or, in dev, migrate:fresh; in production, never run both.
  • Silent no-op = no rows. If users can reuse old passwords, check table contents before touching the rule. Empty table ⇒ broken recording path (observer missing, manual update() bypass, or event listener not registered because the provider wasn't discovered in a non-standard bootstrap).
  • Rule ordering matters. Put 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 cost changes. History stores hashes verified with 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.
  • Test isolation. With 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.
  • Don't apply it everywhere. It's for human-chosen passwords only — not API tokens, not seeded demo accounts, not impersonation flows. Applying it to a password_confirmation-less field or an admin "set user password" screen without passing the target user produces confusing behaviour.
  • Config quirks. If a config file is merged, run php artisan config:clear after changing retention/count values; cached config is a frequent reason limit changes appear ignored in production.
  • Extension points to prefer (in order of stability): the validation rule in your own rule arrays, 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.
  • Upgrade hygiene. Because 2.0.2's headline feature is a generation-time change, the correct upgrade recipe is: bump the constraint, 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console