pijler / user-devices
This package provides support for managing user devices.
Requires
- php: ^8.3
- laravel/framework: ^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.29
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.4
- pestphp/pest-plugin-laravel: ^4.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
User Devices
🚀 Introduction
This package provides support for managing user devices in Laravel. Track login devices, detect new logins, send notifications, and allow users to block suspicious devices—useful for security monitoring, session management, and multi-device applications.
🧩 Features
- ✅ Device tracking: Automatic tracking of IP address, user agent, location, and session ID
- ✅ Device identification: Recognize devices by a long-lived cookie, user agent, or IP + user agent
- ✅ New login detection: Identifies first-time logins from new devices
- ✅ Email notifications: Sends alerts when a new device logs in, on login attempts, and on failed logins
- ✅ Configurable events: Enable or disable listeners per auth event (authenticated, attempting, failed)
- ✅ Location from IP: Optional geolocation via callback
- ✅ Block device: Signed links to block suspicious devices in all notification types (invalidates session when blocked)
- ✅ Integrated middleware: Protect routes from blocked devices
- ✅ Block login check: Prevent blocked devices from attempting login via
isCurrentDeviceBlocked() - ✅ Model trait: Simple Eloquent integration
- ✅ Flexible configuration: Custom models and callbacks
📦 Installation
You can install the package via Composer:
composer require pijler/user-devices
🗄️ Publishing
Publish the package config (optional):
php artisan vendor:publish --tag=user-devices-config
Publish the package migrations:
php artisan vendor:publish --tag=user-devices-migrations
Run the migrations:
php artisan migrate
⚙️ Configuration
Config File
// config/user-devices.php return [ 'events' => [ 'failed' => true, // Track failures, notify when new device 'attempting' => false, // Track attempts, notify when new device 'authenticated' => true, // Save device + send new login notification ], 'credential_key' => 'email', // Key to find user from credentials (attempting/failed) 'identify_by' => 'cookie', // 'cookie' or a list of attributes, e.g. ['ip_address', 'user_agent'] 'cookie' => [ 'name' => 'user_device', // Cookie that stores the device token 'lifetime' => 259200, // Minutes (180 days) ], ];
Device Identification
The identify_by option controls how a request is matched to a stored device:
| Value | Matches by | Trade-offs |
|---|---|---|
'cookie' (default) |
A random token stored in a long-lived cookie | Recognizes the device across IP and browser updates. A login without the cookie (e.g. stolen credentials) is always a new device. Clearing cookies or private windows show up as new devices. |
['user_agent'] |
User agent only | Survives IP changes, but different devices with the same browser are treated as one, and the user agent is easy to spoof. |
['location', 'user_agent'] |
Location + user agent | Tolerates IP changes within the same location, while a login from elsewhere is a new device. Requires DeviceCreator::resolveLocationUsing(). |
['ip_address', 'user_agent'] |
IP address + user agent | Strictest match, but mobile networks, VPNs and Wi-Fi/4G switches create many "new device" alerts. |
Any combination of location, ip_address and user_agent is accepted; all listed attributes must match. Other values throw an InvalidIdentifyByException (extends InvalidArgumentException). Since the location is derived from the IP address, combining location with ip_address adds nothing. Using location without DeviceCreator::resolveLocationUsing() throws a MissingLocationResolverException, as every device would have a null location.
With the cookie strategy:
- The token is stored hashed (SHA-256) in the
device_tokencolumn. The cookie is encrypted by Laravel'sEncryptCookiesmiddleware. - The location, IP address and user agent are still saved and updated with the latest values for display in notifications.
- The cookie is sent when a new token is generated and renewed at most once a day, so
lifetimecounts from the device's last use. This requires theAddQueuedCookiesToResponsemiddleware (included in thewebgroup). - Clients that don't send the cookie back (e.g. bots ignoring
Set-Cookie) are matched by IP + user agent, so repeated attempts don't create a new device and notification each time. Their generated token never replaces the token of a device already identified by cookie. - Requests without a session (e.g. stateless APIs) can't keep the cookie, so they are matched by user agent among devices that have no token.
- Devices saved before the
device_tokencolumn existed are matched by IP + user agent once and then receive a token, so upgrading doesn't trigger a wave of notifications.
Note: Blocking a device is a convenience, not a security boundary: any device identifier can be dropped or changed by the client. Blocking also invalidates the device session and the remember token; for compromised credentials, the user should change their password.
DeviceCreator
The package works out-of-the-box, but you can customize the behavior:
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\URL; use Pijler\UserDevices\DeviceCreator; use Pijler\UserDevices\Notifications\AuthenticatedLoginNotification; class AppServiceProvider extends ServiceProvider { public function boot(): void { // Use custom user model DeviceCreator::useUserModel(CustomUser::class); // Use custom user device model DeviceCreator::useUserDeviceModel(CustomUserDevice::class); // Customize user agent generation DeviceCreator::userAgentUsing(fn ($userAgent) => substr($userAgent, 0, 255)); // Control when to send notifications (e.g. disable in local/staging) DeviceCreator::shouldSendNotificationUsing(function ($user, $device) { return ! app()->environment('local'); }); // Resolve location from IP (optional) DeviceCreator::resolveLocationUsing(function (string $ip) { $geo = geoip($ip); return $geo->city ? "{$geo->city}, {$geo->country}" : $geo->country; }); // Customize the notification email AuthenticatedLoginNotification::toMailUsing(function ($notifiable, $device) { $expire = Config::get('auth.verification.expire', 60); $blockUrl = URL::temporarySignedRoute( name: 'user-devices.block', expiration: Carbon::now()->addMinutes($expire), parameters: [ 'id' => $device->getKey(), 'hash' => sha1($device->getKey()), ], ); return (new MailMessage) ->subject('New device detected') ->line('We detected a new login to your account.') ->action('Block device', $blockUrl); }); // Customize the block device URL AuthenticatedLoginNotification::createBlockUrlUsing(function ($device) { return URL::temporarySignedRoute( name: 'your-custom-route-name', expiration: Carbon::now()->addMinutes(120), parameters: [ 'id' => $device->getKey(), 'hash' => sha1($device->getKey()), ], ); }); } }
The block link expiration uses auth.verification.expire (default 60 minutes), same as Laravel's email verification.
⬆️ Upgrading
The identify_by option defaults to cookie, which uses the new device_token column. Existing applications must publish and run the new migration before deploying this version:
php artisan vendor:publish --tag=user-devices-migrations php artisan migrate
To keep the previous behavior, set 'identify_by' => ['ip_address', 'user_agent'].
The UserDevice::isBlocked() scope no longer receives an IP address and user agent: it checks the devices matching the current request, following identify_by.
🧠 Usage
1. Using the HasUserDevices Trait
Add the HasUserDevices trait to your User model. Your User model must also use the Notifiable trait for notifications:
use Illuminate\Notifications\Notifiable; use Pijler\UserDevices\Traits\HasUserDevices; class User extends Authenticatable { use HasUserDevices; use Notifiable; }
2. Saving User Devices
The package automatically saves user devices when auth events fire. No manual setup required—just add the HasUserDevices trait to your User model.
- Authenticated: Saves/updates device (IP, user agent, location, session ID). Sends notification only on first login from that device.
- Attempting: Same as above. Finds user by email in credentials (when
events.attemptingis enabled). - Failed: Same as above. Uses user from event or resolves from credentials (when
events.failedis enabled).
All three events match the device using the identify_by strategy, so the same device is updated across requests.
To skip saving the device entirely for a request (e.g. in middleware or controller before authentication):
use Pijler\UserDevices\DeviceCreator; DeviceCreator::ignoreListener();
To ignore only the new login notification (device is still saved, but no email is sent):
DeviceCreator::ignoreNotification();
To control notifications globally (e.g. disable in local/staging, or custom logic per user/device):
DeviceCreator::shouldSendNotificationUsing(fn () => false); DeviceCreator::shouldSendNotificationUsing(fn ($user, $device) => ! $user->isAdmin()); DeviceCreator::shouldSendNotificationUsing(fn ($user, $device) => app()->environment('production'));
3. Block Device Route
When a user receives the new login notification email, they can click a link to block the device. Register a route that handles this request. Blocking invalidates the device's session when using session-based auth. The route must be signed and named user-devices.block:
use Pijler\UserDevices\Http\Requests\BlockDeviceRequest; Route::get('/devices/block/{id}/{hash}', function (BlockDeviceRequest $request) { $request->fulfill(); return redirect()->route('home')->with('message', 'Device blocked successfully.'); })->middleware(['signed', 'throttle:6,1'])->name('user-devices.block');
You can use any path you prefer as long as the route is named user-devices.block and includes the {id} and {hash} parameters. All three notification types (Authenticated, Attempting, Failed) include a block link in the email.
4. Check Blocked Device Before Login
To prevent blocked devices from attempting login, call isCurrentDeviceBlocked() after resolving the user (e.g. by email) and before validating the password. In a custom login controller or FormRequest:
// In your login logic, after resolving the user from credentials (e.g. email) $user = User::where('email', $request->email)->first(); if ($user && $user->isCurrentDeviceBlocked()) { return response()->json(['message' => 'This device has been blocked.'], 423); } // Proceed with login attempt...
Or in a FormRequest's authorize or custom validation:
public function authorize(): bool { $user = User::where('email', $this->email)->first(); return ! ($user && $user->isCurrentDeviceBlocked()); }
5. Using the Middleware
The package includes middleware to block requests from devices the user has blocked:
Route::middleware(['auth', 'check.device'])->group(function () { Route::get('/dashboard', [DashboardController::class, 'index']); });
When a blocked device tries to access a protected route, the middleware returns 423 Locked.
6. Working with the UserDevice Model
use Pijler\UserDevices\Models\UserDevice; // Get user's devices $devices = $user->userDevices; // Block a device (invalidates session if session_id is set) $device = UserDevice::find($id); $device->block(); // Unblock a device $device->unblock(); // Block by ID (static) UserDevice::markAsBlocked($id); // Unblock by ID (static) UserDevice::markAsUnblocked($id);
7. Sending Notifications Manually
$user->sendFailedLoginNotification($device); $user->sendAttemptingLoginNotification($device); $user->sendAuthenticatedLoginNotification($device);
8. Customizing Attempting & Failed Login Notifications
use Pijler\UserDevices\Notifications\AttemptingLoginNotification; use Pijler\UserDevices\Notifications\FailedLoginNotification; AttemptingLoginNotification::toMailUsing(fn ($notifiable, $device) => (new MailMessage) ->subject('Login attempt')->line("IP: {$device->ip_address}")); AttemptingLoginNotification::createBlockUrlUsing(fn ($device) => URL::temporarySignedRoute(/* ... */)); FailedLoginNotification::toMailUsing(fn ($notifiable, $device) => (new MailMessage) ->subject('Failed login')->line("IP: {$device->ip_address}")); FailedLoginNotification::createBlockUrlUsing(fn ($device) => URL::temporarySignedRoute(/* ... */));
🧩 API Reference
DeviceCreator
// Configuration DeviceCreator::useUserModel(string $model): void DeviceCreator::useUserDeviceModel(string $model): void DeviceCreator::userAgentUsing(Closure $callback): void DeviceCreator::resolveLocationUsing(Closure $callback): void // (string $ip) => ?string DeviceCreator::shouldSendNotificationUsing(Closure $callback): void // (user, device) => bool // Request context (call before authentication) DeviceCreator::ignoreListener(): void // Skip saving the device for the current request DeviceCreator::ignoreNotification(): void // Skip the new login notification for the current request
UserDevice Model
// Relationships $device->user(): BelongsTo // Actions $device->block(): void // Also invalidates session when session_id is set $device->unblock(): void // Static methods UserDevice::markAsBlocked(mixed $id): void UserDevice::markAsUnblocked(mixed $id): void // Scopes UserDevice::isBlocked(): bool // The device matching the current request is blocked
HasUserDevices Trait
// Methods available on model $model->userDevices(): HasMany $model->currentDevice(): UserDevice // Resolve the current request's device using the identify_by strategy $model->isCurrentDeviceBlocked(): bool // Check if current request's device is blocked (use before login) $model->sendFailedLoginNotification(UserDevice $device): void $model->sendAttemptingLoginNotification(UserDevice $device): void $model->sendAuthenticatedLoginNotification(UserDevice $device): void
AuthenticatedLoginNotification
AuthenticatedLoginNotification::toMailUsing(Closure $callback): void AuthenticatedLoginNotification::createBlockUrlUsing(Closure $callback): void
AttemptingLoginNotification & FailedLoginNotification
AttemptingLoginNotification::toMailUsing(Closure $callback): void AttemptingLoginNotification::createBlockUrlUsing(Closure $callback): void FailedLoginNotification::toMailUsing(Closure $callback): void FailedLoginNotification::createBlockUrlUsing(Closure $callback): void
BlockDeviceRequest
$request->fulfill(): void $request->getDevice(): ?UserDevice
📝 License
Open-source under the MIT license.