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 behindrunningInConsole()alongsidepublishes()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 inboot().- Polymorphic relations are worth the extra setup for anything meant to be reused across different models. A hard
user_idforeign 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.
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.
Enjoying the content?
If this article helped you, you can support the author with a coffee. It keeps new posts coming.
Comments 0
No comments yet
Be the first to share your thoughts.