Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Filament Password Expiry Laravel Package

eightynine/filament-password-expiry

Filament plugin that enforces password expiry in your Laravel admin panel. Define a maximum password age, prompt users to update expired passwords after a set number of days, and redirect them to a secure change-password screen.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and inspect before you wire anything up.

composer require eightynine/filament-password-expiry

With a zero-star / zero-dependent package, the first ten minutes should be spent reading source, not reading docs:

composer show eightynine/filament-password-expiry   # version, requires, Filament constraint
ls vendor/eightynine/filament-password-expiry
cat vendor/eightynine/filament-password-expiry/composer.json
find vendor/eightynine/filament-password-expiry/src -name '*.php' | head -50

Look for, in this order:

  • the service provider (src/*ServiceProvider.php) — what it publishes, what it registers;
  • the plugin class implementing Filament\Contracts\Plugin — this is your configuration surface;
  • the migration stub — the column name (password_changed_at vs password_expires_at vs password_expiry_at) and its nullability;
  • the middleware (if any) — that is where the "forced reset" logic actually lives;
  • the config file, if one is published.

2. Publish and migrate.

php artisan vendor:publish --tag="filament-password-expiry-config"
php artisan vendor:publish --tag="filament-password-expiry-migrations"
php artisan migrate

If no migration tag exists, the package almost certainly expects you to add the column yourself — check the README/source before assuming.

3. Register the plugin on the panel(s) you want protected, in app/Providers/Filament/AdminPanelProvider.php:

->plugin(PasswordExpiryPlugin::make())

4. Make sure your User model satisfies the contract. Typically the package requires a timestamp column plus either a trait or an interface (HasPasswordExpiry). Add it explicitly rather than relying on magic:

class User extends Authenticatable implements FilamentUser, HasPasswordExpiry
{
    use HasFactory, Notifiable, HasPasswordExpiry;
}

First use case: set expiry to 90 days, backfill password_changed_at = users.created_at (or now()) for existing rows, then log in as a seeded user with password_changed_at set 91 days in the past and confirm you land on a reset screen instead of the dashboard. That single test tells you whether the package works, how it redirects, and which routes are exempt.

Implementation Patterns

Login-time (lazy) evaluation vs. scheduled evaluation. Most packages in this space do the cheap thing: a middleware or login hook checks password_changed_at + expiryDays < now() and redirects to a forced-reset page. That means dormant accounts never get prompted, and the check costs one comparison per request. If the package ships an artisan command or notification job, prefer scheduling that for the "warn at 83 days, force at 90" UX:

// routes/console.php
Schedule::command('password-expiry:notify')->dailyAt('08:00');

If it does not ship one, write your own command against the same column rather than fighting the package.

Multi-panel setups. Register the plugin only where it belongs, and use separate policies for staff vs. customers:

// AdminPanelProvider
->plugin(PasswordExpiryPlugin::make()->expiryDays(60)->forceChange(true))

// CustomerPanelProvider
->plugin(PasswordExpiryPlugin::make()->expiryDays(365))

Verify in the plugin class that expiryDays() (or whatever the setter is called) is actually per-instance and not read from a static/config singleton — a common bug in small packages is that the last panel to boot wins.

Exempting machine accounts. API tokens, service users, and integration accounts should not be locked out. Guard with a closure on the model or a column:

public function shouldExpirePassword(): bool
{
    return $this->password_expires_at !== null
        && ! $this->hasRole('service-account');
}

Check whether the package calls a method you can override, or only inspects the column. Prefer overriding over patching.

Writing the timestamp in exactly one place. Centralise it in an observer or a model mutator so every code path that changes a password updates the clock:

protected static function booted(): void
{
    static::updating(function (User $user) {
        if ($user->isDirty('password')) {
            $user->password_changed_at = now();
        }
    });
}

This is the single most important integration point. If you also let users edit their profile via ->profile() on the panel, confirm that saving the profile form does not touch password (or touches it with an identical hash) — otherwise you silently reset everyone's expiry clock every time they change their job title, and the package does nothing.

Testing. Freeze time and assert the redirect:

it('forces a reset after expiry', function () {
    $user = User::factory()->create([
        'password_changed_at' => now()->subDays(91),
    ]);

    $this->actingAs($user)
        ->get('/admin')
        ->assertRedirect('/admin/password-reset-required'); // verify actual route
});

Use Carbon::setTestNow() / $this->travel(91)->days() rather than manipulating the config each test.

Logging and audit. Emit an event or write to your activity log when a reset is forced — support tickets about "why am I locked out" are otherwise guesswork.

Gotchas and Tips

Backfill before you enforce. Existing users will have NULL in the timestamp column. Depending on the package's comparison, NULL + 90 days < now() may evaluate as "expired" and lock out your entire userbase on deploy. Either backfill (UPDATE users SET password_changed_at = created_at) or confirm the package treats NULL as "never expires". Test this explicitly — it is the number one way this class of package causes an outage.

Redirect loops. The forced-reset middleware must allowlist the reset page, the logout route, and Filament's static assets//livewire/*. If it doesn't, you get an infinite redirect and no way to recover except php artisan tinker. Test the recovery path before shipping, and keep an emergency artistan command to null out the column.

Timezone and casting. Add the column to $casts as datetime, and make sure the DB session timezone matches PHP's. A 90-day policy that fires at day 89 or day 91 because of a UTC/local mismatch generates weird bug reports.

Password::defaults() and uncompromised(). Filament's password field often uses Password::defaults(), which can include the Have I Been Pwned rule — that makes your test suite hit the network and makes seeding slow/flaky. Scope it: Password::defaults(fn () => Password::min(12)->mixedCase()->numbers()) in AppServiceProvider for non-production, or override the rule list on the reset form.

Sessions and "logout other devices". Forcing a password change should generally be paired with session invalidation:

Auth::logoutOtherDevices($newPassword);

Check whether the package does this; if not, an attacker holding a stolen session keeps it after the victim resets. Also decide whether to invalidate remember_token.

Don't let a superadmin lock themselves out. If you enforce expiry on the admin panel, keep a break-glass path: an artisan command (php artisan password-expiry:clear [email protected]) or a config/env kill switch.

Configuration quirks. These small packages frequently read config at boot rather than per-request, so config()->set() inside a test or a middleware won't take effect after the container has resolved the plugin. Clear config cache after publishing: php artisan config:clear.

Extension points to prefer. Published views (override the reset page markup), published config (policy), a notification class (customise the "your password expires in 7 days" mail), and the model method/closure for the expiry check. Avoid editing vendor/ — with zero dependents and no star history, upstream fixes are not guaranteed; if you must patch, fork or use cweagans/composer-patches and pin the version exactly ("eightynine/filament-password-expiry": "1.0.*").

Compatibility check first. Confirm the package's Filament constraint covers your installed major version (v3 vs v4 differ substantially in plugin registration and panel middleware). composer show tells you in one line; don't discover it via a Class not found at runtime.

Have an exit plan. With 0 stars and 0 dependents, budget for replacing it. The logic is genuinely small — a column, a model hook, one middleware, one route — so if the package turns out to be unmaintained or incompatible, reimplementing it in ~80 lines is a realistic fallback and often the better long-term call.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox