bbs-lab/filament-password-rotation

Force Filament users (or any authenticatable) to rotate their password every N days, with a native forced-change page, reuse prevention and expiry warnings.

Maintainers

Package info

github.com/BBS-Lab/filament-password-rotation

pkg:composer/bbs-lab/filament-password-rotation

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.0 2026-07-23 17:46 UTC

This package is auto-updated.

Last update: 2026-07-23 19:32:15 UTC


README

Latest Version on Packagist Tests Total Downloads

Force any authenticatable model to rotate its password every N days. When the password expires, a Filament panel middleware redirects the logged-in user to a native, full-page Filament change-password screen. Light, secure, and enabled with a single ->plugin() line.

The rotatable subject is not tied to the User model: everything keys off the MustRotatePassword interface on whatever user the panel authenticated, and the password history is polymorphic, so any model works.

Screenshots

Each screenshot is split diagonally: light theme (top-left) / dark theme (bottom-right).

Forced password-change screen โ€” shown when a password has expired:

Forced password-change screen

Reset action (expiry_action: reset) โ€” instead of the in-panel form, a single button that emails a reset link and signs the user out:

Reset-link action screen

Expiry warning โ€” a callout at the top of every panel page while the password nears expiry:

Expiry warning callout

Sign-in screen

Sign-in screen

Features

  • ๐Ÿ”’ Forces a password change once the current one is older than days
  • ๐Ÿงญ Filament panel middleware redirect to a native full-page Livewire change screen
  • ๐Ÿงฌ Interface-gated: only enforced when the authenticated user implements MustRotatePassword
  • ๐Ÿ—‚๏ธ Polymorphic password history โ€” any authenticatable model is supported
  • ๐Ÿšซ Reuse prevention: rejects the last N hashed passwords, and forbids reusing the current one
  • ๐Ÿ‘‹ First-login enforcement: admin-provisioned accounts must set their own password
  • โฐ Expiry warning: a Filament callout banner at the top of every panel page while the password is within the warning window
  • ๐Ÿ“Š password-rotation:report Artisan command to audit account status
  • ๐Ÿ“ฃ A PasswordRotated event you can listen to
  • ๐ŸŒ English & French translations, publishable
  • ๐Ÿงช 100% line coverage, PHPStan level 8, no final classes, strict types everywhere

Requirements

  • PHP ^8.2
  • Filament ^4.0 || ^5.0
  • Laravel ^11.0 || ^12.0 || ^13.0

Installation

composer require bbs-lab/filament-password-rotation

The service provider auto-registers via Laravel package discovery. The password_histories migration runs automatically. Publish the config and translations if you want to tweak them:

# Filament-specific config (slug, expiry_action)
php artisan vendor:publish --tag=filament-password-rotation-config

# Base rotation config (days, history_count, warn_days, โ€ฆ)
php artisan vendor:publish --tag=laravel-password-rotation-config

# Translations (en, fr)
php artisan vendor:publish --tag=filament-password-rotation-translations

# The users-table migration stub (adds the rotation column) โ€” edit it per table before migrating
php artisan vendor:publish --tag=laravel-password-rotation-user-migration

php artisan migrate

The published users migration adds the nullable rotation column (password_changed_at by default) and backfills existing rows to now() so no one is locked out on deploy. Edit the stub to target the correct table if your rotatable model is not users.

Enable the plugin

Register the plugin on the panel you want to protect, in your PanelProvider:

use BBSLab\FilamentPasswordRotation\FilamentPasswordRotationPlugin;
use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->plugin(FilamentPasswordRotationPlugin::make());
}

That one line appends the EnsurePasswordIsNotExpired middleware to the panel's authenticated stack and registers the forced-change page route. There is no zero-config auto-registration: Filament has no clean hook to inject a page route into every panel, so registration is always explicit.

Quick start

Add the interface and trait to the authenticatable model you want to rotate:

use BBSLab\LaravelPasswordRotation\Concerns\RotatesPassword;
use BBSLab\LaravelPasswordRotation\Contracts\MustRotatePassword;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable implements MustRotatePassword
{
    use RotatesPassword;
}

The trait implements the whole interface, casts the rotation column to a datetime, stamps it on every password change, records history, and fires PasswordRotated. On create it stamps the column too โ€” unless force_on_first_login is on (the default), in which case the column is left null so the account is forced to set its own password on first login. Run the published migration and you are done โ€” expired users are redirected on their next full page load in the panel.

Configuration

The generic rotation keys live in the base package's config/laravel-password-rotation.php; the Filament-specific keys (slug, expiry_action) live in config/filament-password-rotation.php. All are driven by an environment variable.

Config key Env var Default Description
enabled PASSWORD_ROTATION_ENABLED true Master switch. When false the package is inert: no forced change, no warnings.
morph_key_type PASSWORD_ROTATION_MORPH_KEY_TYPE null Key type for the password-history authenticatable_id column. Set to uuid/ulid for string-keyed users; null follows Laravel's default.
days PASSWORD_ROTATION_DAYS 90 How many days a password stays valid, counted from the rotation column.
column PASSWORD_ROTATION_COLUMN password_changed_at The timestamp column storing when the password last changed. Override per model via passwordRotationColumn().
force_on_first_login PASSWORD_ROTATION_FORCE_FIRST_LOGIN true Treat a null timestamp as expired, so provisioned accounts must set their own password.
require_current_password PASSWORD_ROTATION_REQUIRE_CURRENT true Require confirmation of the current password on the change screen.
history_count PASSWORD_ROTATION_HISTORY_COUNT 3 Number of previous (hashed) passwords remembered and rejected. 0 disables history entirely.
warn_days PASSWORD_ROTATION_WARN_DAYS 7 Show a Filament notice this many days before expiry. 0 disables the warning.
slug PASSWORD_ROTATION_SLUG password/rotate The change screen lives at {panel}/{slug}. A / becomes a . in the route name. Change only on a collision.
expiry_action PASSWORD_ROTATION_EXPIRY_ACTION change change shows the in-panel change form; reset shows a button that emails a reset link and signs the user out. reset requires the model to implement CanResetPassword.
models โ€” ['App\Models\User'] Models scanned by password-rotation:report. The middleware does not use this list.

The slug must be set before the panel registers its routes โ€” via the published config file, an .env var, or config:cache โ€” never with a runtime config()->set() after boot: the route is compiled once at boot and its name is pinned then.

The models array only feeds the report command. The middleware works off the interface on the authenticated user, so any model is enforced regardless of this list.

Usage

The forced-change flow

On every full panel page load, EnsurePasswordIsNotExpired checks the authenticated user. If it implements MustRotatePassword and its password has expired, the user is redirected to the forced-change page โ€” a full-page Filament Livewire component rendered with the login-style simple layout, so it looks native without any extra build step. The page route and the panel logout route are skipped so the redirect never loops. On a successful update the user is sent back to the panel dashboard with a success Filament notification.

Reuse prevention

When history_count > 0, every password change is hashed and stored in the polymorphic password_histories table (only the newest N rows are kept per user). The change screen rejects any of those previous passwords, and always rejects reusing the current one. You can reuse the rule directly:

use BBSLab\LaravelPasswordRotation\Rules\PasswordNotReused;

$request->validate([
    'password' => ['required', 'confirmed', new PasswordNotReused($user)],
]);

First login

With force_on_first_login enabled (the default), a null rotation column counts as expired. Admin-provisioned accounts are therefore forced to set their own password before using the panel. The published migration backfills existing rows so current users are not caught out.

Expiry warning

When warn_days > 0, a still-valid user whose password is within the warning window sees a Filament warning callout at the top of every panel page, injected via the PAGE_START render hook. It renders on each page load while inside the window and stays inert otherwise.

The password-rotation:report command

Audit the accounts declared in config('laravel-password-rotation.models'):

# Only accounts that are expired or expiring within warn_days
php artisan password-rotation:report

# Every account
php artisan password-rotation:report --all

It renders a table of identifier, last-changed, expires-at and status (expired / expiring soon / ok). Classes that do not exist or do not implement MustRotatePassword are skipped.

The PasswordRotated event

Dispatched after a password change is persisted (not on the initial create). Listen for it to log, notify, or revoke sessions:

use BBSLab\LaravelPasswordRotation\Events\PasswordRotated;
use Illuminate\Support\Facades\Event;

Event::listen(function (PasswordRotated $event) {
    logger()->info('Password rotated', ['user' => $event->authenticatable->getKey()]);
});

Testing

composer test            # Pest suite
composer test-coverage   # 100% line coverage on src/
composer analyse         # PHPStan level 8
composer format          # Pint (laravel preset + strict types)

A full embedded Filament app (via Orchestra Workbench) lets you exercise the flow in a real panel:

composer serve   # boots the admin panel at http://localhost:8000/admin

Security

Passwords are stored only as hashes โ€” the history table keeps a copy of the already-hashed value, never plaintext. Reuse checks run through Hash::check. Hash::make is idempotent against a hashed cast, so passwords stay single-hashed. If you discover a security issue, please email paris@big-boss-studio.com instead of using the issue tracker.

Nova

Using Laravel Nova instead of Filament? Its twin, bbs-lab/nova-password-rotation, brings the same password rotation to Nova.

Changelog

Please see CHANGELOG for what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Credits

License

The MIT License (MIT). Please see License File for more information.