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.

Maintainers

Package info

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

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

Transparency log

Statistics

Installs: 31

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.0 2026-07-23 18:05 UTC

This package is auto-updated.

Last update: 2026-07-23 19:34:04 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 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:

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 Nova notification while the password nears expiry:

Expiry warning notification

Sign-in screen

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: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
  • 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 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 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.