beliven-it / laravel-lockout
A small Laravel package to lock out a user after X failed login attempts.
Fund package maintenance!
Beliven
Installs: 10
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 1
pkg:composer/beliven-it/laravel-lockout
Requires
- php: ^8.4|^8.3
- illuminate/contracts: ^11.0||^12.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0.0||^9.0.0
- pestphp/pest: ^4.0||^3.0
- pestphp/pest-plugin-arch: ^4.0||^3.0
- pestphp/pest-plugin-laravel: ^4.0||^3.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^12.0||^11.0
This package is auto-updated.
Last update: 2025-12-24 09:13:55 UTC
README
A small, opinionated Laravel package that locks accounts after repeated failed login attempts. It provides both in-memory throttling (cache counters) and optional persistent locks stored in the database, plus unlock notifications via a temporary signed link.
Quick Start (Usage)
These steps get you from install to a working lockout flow in your app.
- Install with Composer:
composer require beliven-it/laravel-lockout
- Publish configuration and migration stubs:
php artisan vendor:publish --tag="lockout-config" php artisan vendor:publish --tag="lockout-migrations" php artisan migrate
- Add the trait to your authentication model (e.g.
App\Models\User) and implement theLockableModelcontract:
use Beliven\Lockout\Traits\HasLockout; use Beliven\Lockout\Contracts\LockableModel; class User extends Authenticatable implements LockableModel { use HasLockout; }
- Protect your login route with the middleware:
use Beliven\Lockout\Http\Middleware\EnsureUserIsNotLocked; Route::post('/login', [LoginController::class, 'login']) ->middleware(EnsureUserIsNotLocked::class);
The package also expose an additional middleware for ensure that locked users cannot access to any routes:
use Beliven\Lockout\Http\Middleware\EnsureUserCannotAccessWhenLocked; Route::middleware([EnsureUserCannotAccessWhenLocked::class])->group(function () { // protected routes for authenticated users });
- Scheduler (recommended)
- The package includes a console command
lockout:pruneto remove old records according to the retention values inconfig('lockout.prune')(prune.lockout_logs_daysandprune.model_lockouts_days). In production you should schedule this command to run regularly via Laravel's scheduler (and ensure your cron runsschedule:run).
Example: register scheduled tasks in routes/console.php
<?php //.... // routes/console.php use Illuminate\Support\Facades\Schedule; Schedule::command('lockout:prune --force')->dailyAt('02:00')->withoutOverlapping(); // Optional: separate schedules for logs/models, or different cadences. Schedule::command('lockout:prune --only-logs --force')->dailyAt('03:00')->withoutOverlapping(); Schedule::command('lockout:prune --only-model --force')->weekly()->withoutOverlapping();
Tips
- You can override the retention periods at runtime by passing
--days-logsand/or--days-modelsto the command, e.g.php artisan lockout:prune --days-logs=30 --days-models=180 --force. - Ensure
lockout.prune.enabledin your config istrue(default) for the scheduled prune to actually perform deletions.
- Test manually:
- Attempt failing logins for the same identifier until the threshold is reached.
- Inspect the
lockout_logstable and (if configured)model_lockoutsfor persistent locks. - If
unlock_via_notificationis enabled, the package will try to send a temporary signed unlock link to a notifiable model.
Configuration (summary)
Full options are in config/lockout.php. Here are the most important settings and their defaults:
return [ 'login_field' => 'email', 'unlock_via_notification' => true, 'notification_class' => \Beliven\Lockout\Notifications\AccountLocked::class, 'notification_channels' => ['mail'], 'max_attempts' => 5, 'decay_minutes' => 30, 'cache_store' => 'database', 'auto_unlock_hours' => 0, 'unlock_redirect_route' => 'login', 'unlock_link_minutes' => 1440, 'logout_on_login' => false, 'prune' => [ 'enabled' => true, 'lockout_logs_days' => 90, 'model_lockouts_days' => 365, ], ];
Recommendation:
- Use a durable cache (Redis or database) in production for counters (
cache_store) so counters persist across processes and restarts.
Unlock route & controller
The package generates signed unlock links using the route name lockout.unlock.
If you publish the package routes or want to register the route yourself, ensure a route with that name exists and points to the unlock controller:
use Beliven\Lockout\Http\Controllers\UnlockController; Route::get('/lockout/unlock', UnlockController::class)->name('lockout.unlock');
The unlock link is temporary and validates the signature before performing the unlock action. The link lifetime is configurable via the lockout.unlock_link_minutes configuration key (default: 1440 minutes, i.e. 24 hours).
--
Logout on Login
If you want to ensure that when a locked user attempts to log in, any existing sessions are terminated, you can enable the logout_on_login configuration option. This is particularly useful in scenarios where you want to prevent locked users from maintaining active sessions.
Also, is useful in scenario like third party admin panels (e.g. Filament, Nova) where you can't easily add the lockout middleware to the login route.
You can enable this feature by setting the logout_on_login option to true in your config/lockout.php file:
'logout_on_login' => true,
You can also override the logoutAfterLogin method on your model that uses the HasLockout trait to customize the logout behavior further. The default behavior is:
public function logoutOnLockout(?string $guard = null): bool { // Default common case behavior // (to be overridden in concrete models if needed) Auth::guard($guard)->logout(); session()->invalidate(); session()->regenerateToken(); return true; }
API Reference (quick)
Use the Lockout facade or the Beliven\Lockout\Lockout service from the container.
Lockout::getAttempts(string $id): int— return attempts count for identifierLockout::incrementAttempts(string $id): void— increment attempts counterLockout::hasTooManyAttempts(string $id): bool— whether attempts >= thresholdLockout::attemptLockout(string $id, object $data): bool— increment, create log, dispatch EntityLocked when threshold reached; returns whether locked after callLockout::attemptSendLockoutNotification(string $id, object $data): void— send the unlock notification if enabled & model is notifiableLockout::getLoginField(): stringLockout::getLoginModelClass(): stringLockout::getLoginModel(string $identifier): ?Model— tries to resolve a model from configured providerLockout::lockModel(Model $model, array $options = []): ?ModelLockout— create persistent lock via relationLockout::unlockModel(Model $model, array $options = []): ?ModelLockout— mark lock as unlocked, clear attempts, dispatch EntityUnlockedLockout::clearAttempts(string $id): void— clear cached counter
These helpers are also used internally by the HasLockout trait; your models can call $model->lock() and $model->unlock() which delegate to the service.
Customization & Extensibility
- Replace notification class: set
lockout.notification_classto your own class implementing the same constructor signature (identifier, duration, url). - Change notification channels: set
lockout.notification_channelsto e.g.['mail', 'database']. - Replace or extend listeners: bind your own listeners for
EntityLocked/EntityUnlockedin yourEventServiceProvider. - Replace login model resolver: set
auth.providers.users.modelto change how the package resolves a model for an identifier.
Example: custom notification class in config/lockout.php:
'notification_class' => App\Notifications\MyAccountLocked::class,
Troubleshooting
- Notifications not sent:
- Ensure
unlock_via_notificationistrue. - The resolved model must implement
notify()(useNotifiabletrait). - Verify your mail driver and config (MAIL_* env vars).
- Ensure
- Counters not behaving:
- Check
cache_storeconfig; if usingarraystore counters reset on each request-run. Useredisordatabasein production.
- Check
- Signed unlock links failing:
- Ensure your app URL / APP_KEY is set and consistent.
- Check the route name
lockout.unlockand that the route is reachable.
- Coverage / testing:
- To generate coverage locally you need Xdebug or phpdbg enabled. Example:
- With Xdebug:
php -d xdebug.mode=coverage vendor/bin/pest --coverage --coverage-text - With phpdbg:
phpdbg -qrr vendor/bin/pest --coverage --coverage-text
- With Xdebug:
- On CI use a matrix image with Xdebug enabled.
- To generate coverage locally you need Xdebug or phpdbg enabled. Example:
Developing (run tests, coverage & local development)
If you're working on the package itself locally:
- Install dev dependencies:
composer install --dev
- Run tests:
vendor/bin/pest
- Generate coverage (requires Xdebug or phpdbg):
# Xdebug php -d xdebug.mode=coverage vendor/bin/pest --coverage --coverage-text --coverage-clover=coverage.xml # phpdbg phpdbg -qrr vendor/bin/pest --coverage --coverage-text --coverage-clover=coverage.xml
- Useful notes:
- Use
config()->set(...)in tests to override settings (examples in tests). - Database migrations for tests are in the test bootstrap; if adding schema changes, update migration stubs and test setup.
- To run a single test file with Pest:
vendor/bin/pest tests/Unit/SomeTest.php
Events & Examples
EntityLocked— fired when threshold is reached. Default listener may create a persistent lock and/or send notification.EntityUnlocked— fired when a persistent lock is cleared. Useful to log unlocks or notify admins.
Example listener registration is done via your EventServiceProvider if you need to override defaults.
Example. Prevent login on filament
// todo
Example: Laravel Nova Action
If you use Laravel Nova, you can expose an Action that lets administrators unlock a user directly from the Nova resource. The Action can resolve the model and call the trait helper $model->unlock(...), which delegates to the package service.
Example Nova Action (concise):
<?php namespace App\Nova\Actions; use Laravel\Nova\Actions\Action; use Laravel\Nova\Fields\ActionFields; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; class UnlockUser extends Action implements ShouldQueue { use Queueable; public function name() { return 'Unlock account'; } /** * Handle the action for the given models. * * @param \Laravel\Nova\Fields\ActionFields $fields * @param \Illuminate\Support\Collection $models */ public function handle(ActionFields $fields, $models) { foreach ($models as $model) { // call the HasLockout trait helper which delegates to the Lockout service // you can pass optional metadata like reason/actor/requestData $model->unlock([ 'reason' => 'Unlocked by admin via Nova', 'actor' => auth()->user()?->id ?? null, ]); } return Action::message('Selected accounts have been unlocked.'); } public function fields() { return []; } }
Add the action to your Nova Resource (e.g. User resource):
// in app/Nova/User.php public function actions(Request $request) { return [ new \App\Nova\Actions\UnlockUser, ]; }
Notes:
- The Nova Action calls the model method
unlock()directly; the trait delegates to the package service so the unlock behaviour (clearing attempts, dispatching events) remains consistent. - If you need to customize the service used by the trait, you can override
resolveLockoutService()on your model (see Customization & Extensibility).
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.
