Skip to content

Preventing Password Reuse in Laravel: Building a Password History Package

Prevent password reuse in Laravel with a free open-source package. Add polymorphic password history tracking, a validation rule, and scheduled pruning to any Eloquent model - User, Admin, or custom auth model - in minutes via Composer install.

Dasun Kalupahana 3 min read 232 views 0 comments

Password rotation policies are common in security-conscious applications — banking systems, HR platforms, government portals — anywhere compliance requires users to change their password periodically. But rotation alone is a weak control if users can just cycle back and forth between two or three favorite passwords every time they're prompted to change one.

Laravel's validation layer gives you strong primitives out of the box — Password::min(8)->mixedCase()->numbers()->symbols() covers complexity — but it has no built-in concept of history. There's no way to say "this password can't match any of your last five" without writing that logic yourself.

So I built laravel-password-history — a small, focused Composer package that adds exactly that, without assuming your app only has one kind of user.


The core idea: polymorphic history, not a hard foreign key

Most password-history implementations I've seen hard-code a user_id column, which quietly assumes every app has exactly one authenticatable model called User. That's not always true — plenty of apps authenticate Admin, Customer, or multiple guards side by side.

Instead, the package uses Laravel's polymorphic relationships:

Schema::create('password_histories', function (Blueprint $table) {
    $table->id();
    $table->morphs('historyable'); // historyable_id + historyable_type
    $table->string('password');
    $table->timestamp('created_at')->useCurrent();
});

morphs('historyable') expands into historyable_id, historyable_type, and an index across both. That one change means any Eloquent model can adopt password history tracking — not just User — by adding a single trait.


Wiring it into a model

use DevDasun\PasswordHistory\Traits\HasPasswordHistory;

class User extends Authenticatable
{
    use HasPasswordHistory;
}

The trait exposes two methods that do the real work:

public function recordPasswordHistory(string $hashedPassword): void
{
    $this->passwordHistories()->create(['password' => $hashedPassword]);

    $limit = config('password-history.limit', 5);

    $this->passwordHistories()
        ->skip($limit)
        ->take(PHP_INT_MAX)
        ->pluck('id')
        ->each(fn ($id) => PasswordHistory::destroy($id));
}

public function passwordWasUsedBefore(string $plainPassword): bool
{
    $limit = config('password-history.limit', 5);

    return $this->passwordHistories()
        ->take($limit)
        ->get()
        ->contains(fn ($history) => Hash::check($plainPassword, $history->password));
}

Every time a password is recorded, history beyond the configured limit is pruned automatically — no unbounded table growth, no manual cleanup required for the common case.


Hooking into validation

The package ships a ValidationRule implementation so reuse-checking slots directly into Laravel's normal validation array:

use DevDasun\PasswordHistory\Rules\DifferentFromHistory;
use Illuminate\Validation\Rules\Password;

$request->validate([
    'password' => [
        'required',
        'confirmed',
        Password::min(8)->mixedCase()->numbers()->symbols(),
        new DifferentFromHistory,
    ],
]);

If the submitted password matches any of the user's last N passwords (checked via Hash::check(), never plaintext comparison), validation fails with a translatable message:

You have used this password before. Please choose a password you haven't used in the last 5 changes.

Recording the password itself isn't automatic — deliberately. Laravel has no single universal "password changed" event that fires consistently across registration, password reset, and profile updates, so the package leaves that one line explicit:

$user->forceFill(['password' => Hash::make($request->password)])->save();
$user->recordPasswordHistory($user->password);

Configuration and scheduled pruning

The history depth is configurable per app:

<?php
// config/password-history.php

return [
    'limit' => env('PASSWORD_HISTORY_LIMIT', 5),
];

And for cases where records go orphaned — say, a user is force-deleted without cascading — there's an Artisan command you can put on the scheduler:


Installation

composer require devdasun/password-history
php artisan vendor:publish --tag=password-history-migrations
php artisan migrate

A separate ^1.0.1 release line supports Laravel 12 on PHP 8.2, for projects that haven't upgraded yet.


What I learned building this

A few things worth calling out for anyone building their own Laravel package:

  • loadTranslationsFrom() must run on every request, not just in console. I initially gated it behind runningInConsole() alongside publishes() calls — which meant translations worked fine in Artisan/Tinker but silently fell back to raw keys during real web requests. Translation loading needs to happen unconditionally in boot().
  • Polymorphic relations are worth the extra setup for anything meant to be reused across different models. A hard user_id foreign key is simpler to write once, but it locks the package into assumptions about your app's auth structure.
  • Packagist won't let you silently retag a published version anymore. If a release has a bug, ship a patch version — don't try to force-push a tag to "fix" what's already published. It's a good discipline to build in from day one.

Try it

The package is open source under MIT: github.com/DasunMuthuruwan/laravel-password-history. Issues, PRs, and feedback are welcome — especially if you're using it with a non-standard auth setup (multiple guards, custom authenticatables) and hit edge cases I haven't.

composer require devdasun/password-history

 

Get one practical tip every week

New tutorials, fixes and free cheat sheets from DEV Talk, straight to your inbox. No spam, unsubscribe in one click.

Have a question about this post?

Ask the community: other developers and the author can answer.

Ask the community

Get notified about new articles

One click, no email needed. Turn it off anytime with the bell at the top.

Get new articles on your iPhone

Add DEV Talk to your Home Screen, open it, then tap the bell.

Rate this post
0.0
Share

Enjoying the content?

If this article helped you, you can support the author with a coffee. It keeps new posts coming.

Buy me a coffee (opens in a new tab)

Comments 0

Be kind. Comments with links are reviewed before they appear.

No comments yet

Be the first to share your thoughts.

Get new articles in your inbox

Practical tutorials and deep dives, about once a week. No spam, unsubscribe anytime.

Privacy policy

Add DEV Talk to your Home Screen

  1. 1 Tap the Share button in Safari's toolbar.
  2. 2 Scroll down and tap Add to Home Screen.
  3. 3 Tap Add. Open it from the Home Screen to read like an app and to turn on notifications with the bell.

We use cookies to improve your experience and analyze traffic. By continuing, you agree to our Privacy Policy.