bbs-lab / nova-password-rotation
Force Laravel Nova users (or any authenticatable) to rotate their password every N days, with a native Nova-styled change screen, reuse prevention and expiry warnings.
Requires
- php: ^8.2
- bbs-lab/laravel-password-rotation: ^1.1
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
- laravel/nova: ^4.0 || ^5.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- orchestra/workbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- pestphp/pest-plugin-mutate: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
Force any authenticatable model to rotate its password every N days. When the password expires, a Laravel Nova middleware redirects the logged-in user to a native, Nova-styled change-password screen. Light, secure, and works almost out of the box.
The rotatable subject is not tied to the User model: everything keys off the
MustRotatePassword interface on whatever user Nova 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:
Reset action (expiry_action: reset) โ instead of the in-panel form, a single button that emails a reset link and signs the user out:
Expiry warning โ a Nova notification while the password nears expiry:
Sign-in screen
Features
- ๐ Forces a password change once the current one is older than
days - ๐งญ Nova middleware redirect to a native-looking change screen, styled with Nova's own CSS
- ๐งฌ 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 Nova notification a few days before expiry (once per day, best-effort)
- ๐
password-rotation:reportArtisan command to audit account status - ๐ฃ A
PasswordRotatedevent you can listen to - ๐ English & French translations, publishable
- ๐งช 100% line coverage, PHPStan level 8, no
finalclasses, strict types everywhere
Requirements
- PHP
^8.2 - Laravel Nova
^4.0 || ^5.0 - Laravel
^11.0 || ^12.0 || ^13.0
Both Nova majors are exercised in CI. Note that Nova 4 (through its inertiajs/inertia-laravel
dependency) tops out at PHP 8.4 and Laravel 11; on PHP 8.5 or Laravel 12+, use Nova 5. Composer
resolves the right combination for you.
Installation
Because Nova is a paid, private package, make sure your application is already authenticated against
nova.laravel.com, then:
composer require bbs-lab/nova-password-rotation
The service provider auto-registers via Laravel package discovery. It builds on
bbs-lab/laravel-password-rotation, which owns
the generic rotation domain (the interface, trait, history model, rule, event and report command);
Composer pulls it in automatically and its password_histories migration runs on install. Publish what
you want to tweak:
# Nova-specific config (middleware registration, route prefix, expiry action) php artisan vendor:publish --tag=nova-password-rotation-config # Shared rotation config (days, history, column, โฆ) php artisan vendor:publish --tag=laravel-password-rotation-config # Nova UI translations (en, fr) php artisan vendor:publish --tag=nova-password-rotation-translations # Shared validation translations (en, fr) php artisan vendor:publish --tag=laravel-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.
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 Nova.
Configuration
The generic rotation keys live in the base package's config/laravel-password-rotation.php; the
Nova-specific keys live in this package's config/nova-password-rotation.php. Each is driven by an
environment variable.
Shared rotation keys (config/laravel-password-rotation.php, from bbs-lab/laravel-password-rotation):
| 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 |
Warn a user this many days before expiry. 0 disables the warning. |
models |
โ | ['App\Models\User'] |
Models scanned by password-rotation:report. The middleware does not use this list. |
Nova-specific keys (config/nova-password-rotation.php, this package):
| Config key | Env var | Default | Description |
|---|---|---|---|
auto_register_middleware |
PASSWORD_ROTATION_AUTO_MIDDLEWARE |
true |
Append the middleware to config('nova.middleware') automatically. Disable to register it yourself. |
route_prefix |
PASSWORD_ROTATION_ROUTE_PREFIX |
password-rotation |
The change screen lives at {nova}/{prefix}/expired. Change only on a route collision. |
expiry_action |
PASSWORD_ROTATION_EXPIRY_ACTION |
change |
What the expired-password screen does: change shows the in-panel change form; reset emails a reset link and signs the user out. |
The
modelsarray 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 Nova page load, EnsurePasswordIsNotExpired checks the authenticated user. If it implements
MustRotatePassword and its password has expired, the user is redirected to the change screen. Nova 5's
auth pages are Vue/Inertia rather than Blade, so the screen is a self-contained Blade page styled with
Nova's own CSS (loaded when Nova's assets are published) โ it looks native without a JS build. On a
successful update the user is sent back to the Nova dashboard with a success flash.
Because the middleware runs on Nova's web stack only, a mid-session expiry takes effect on the next full page load โ which is acceptable and avoids breaking XHR requests.
With expiry_action set to reset, the screen shows a single Send password reset link button
instead of the change form. Submitting it emails a standard Laravel password reset link (default
broker), signs the user out of the Nova guard (so the emailed guest link is reachable) and redirects to
the Nova login. Nova's login is a Vue SPA, so the "reset link sent" confirmation cannot be shown as a
Nova toast โ the email is the real signal.
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 Nova. 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 receives a Nova
warning notification, at most once per day. It is best-effort: if the nova_notifications table is not
installed, it is silently skipped and Nova keeps working.
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()]); });
Automatic middleware registration
By default the package wires EnsurePasswordIsNotExpired into Nova for you. It does so two ways so it
works on both Nova majors and even when config/nova.php has not been published: it appends the
middleware to config('nova.middleware') (Nova 4 reads this inline, Nova 5 builds its nova router
group from it), and โ once the app has booted โ it also pushes the middleware onto Nova 5's nova
router group. Both are gated by enabled and auto_register_middleware, and neither ever registers
the middleware twice.
Manual middleware registration
If you set auto_register_middleware to false, add the middleware to Nova's stack yourself in
config/nova.php โ it works on both Nova 4 and Nova 5:
use BBSLab\NovaPasswordRotation\Http\Middleware\EnsurePasswordIsNotExpired; 'middleware' => [ // ...Nova's default web middleware... EnsurePasswordIsNotExpired::class, ],
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 Nova app (via Orchestra Workbench) lets you exercise the flow in a real Nova instance:
composer serve # boots Nova at http://localhost:8000/nova
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.
Filament
Using Filament instead of Nova? Its twin,
bbs-lab/filament-password-rotation, brings
the same password rotation to Filament panels.
Changelog
Please see CHANGELOG for what has changed recently. Upgrading from v1.x? See UPGRADE.md.
Contributing
Please see CONTRIBUTING for details.
Credits
License
The MIT License (MIT). Please see License File for more information.



