laravel-device-sessions maintained by kirchdev
📱 laravel-device-sessions
Device-bound login sessions for Laravel — per-device "remember me" tokens, a "where am I signed in" list, and revoke/rename. Privacy-respecting and Fortify-agnostic.
$user->devices; // every browser signed in, with masked IP, OS and last-seen — GitHub-style
That's it. Concurrent device-bound remember-me tokens, a "where am I signed in" list, and revoke/rename — without touching your login controllers.
📦 Install & run
composer require kirchdev/laravel-device-sessions
php artisan vendor:publish --tag=device-sessions-migrations
php artisan migrate
[!IMPORTANT] Publish the config first (
--tag=device-sessions-config) and setdevice-sessions.keys.*+table_names.*before migrating — the migrations read config at run time, andkeys.user_key_typemust match your users-table primary key.
Add the HasDeviceSessions trait to your authenticatable model and point its auth provider at the device-aware driver:
use KirchDev\DeviceSessions\Concerns\HasDeviceSessions;
class User extends Authenticatable
{
use HasDeviceSessions;
}
// config/auth.php
'providers' => [
'users' => [
'driver' => 'device-aware-eloquent', // was 'eloquent'
'model' => App\Models\User::class,
],
],
Then alias the tracking middleware and attach it to your authenticated routes — that's the whole wiring; remember-me logins are now device-bound and the device list populates automatically:
// bootstrap/app.php
use KirchDev\DeviceSessions\Http\Middleware\TrackAuthenticatedUserDevice;
->withMiddleware(fn (Middleware $middleware) => $middleware->alias([
'track.device' => TrackAuthenticatedUserDevice::class,
]))
Route::middleware(['auth', 'track.device'])->group(function () {
// ...authenticated routes
});
✨ Features
- 🔐 Device-bound remember-me — a custom
device-aware-eloquentdriver binds each remember token to a device row + cookie instead of the singleremember_tokencolumn (one active token per device, rotated on login). - 📋 "Where am I signed in" — list active devices (OS, friendly name, masked IP, last-seen), revoke one, revoke all others, or rename — all as plain actions.
- 🕵️ Privacy-respecting — IP masking on by default (IPv4 → /24, IPv6 → /48), swappable via the
IpMaskercontract. - 🔌 Fortify-agnostic — works under any login mechanism; the two-factor cookie bridge auto-wires only when Fortify is present.
- 🧩 Overridable everything — name parsing, OS detection, cookie policy, IP masking and token hashing are contracts with sensible defaults.
- 🧰 Config-driven schema — models, table names and key types (
id/uuid/ulid) all overridable. - 📡 Event-driven — a
DeviceTouchedevent lets you react without the package assuming your schema. - 🧪 Library-grade — Pest 4 + Testbench, no host app needed.
📋 Managing devices
The package ships no routes — every operation is a plain action you call from your own controllers, so the response shape stays yours:
use KirchDev\DeviceSessions\Actions\{ListUserDevices, RevokeUserDevice, RevokeOtherUserDevices, UpdateUserDeviceName};
$devices = app(ListUserDevices::class)->execute($user); // active devices, last-seen first
app(RevokeUserDevice::class)->execute($user, $deviceId); // revoke one (+ its tokens)
app(RevokeOtherUserDevices::class)->execute($user, $currentId); // keep only the current device
app(UpdateUserDeviceName::class)->execute($user, $deviceId, 'Work Laptop');
The middleware exposes the active device as the current_device_id request attribute (also $user->currentDevice()), so you can flag "this device" in the list.
🧩 Overridable contracts
Every host-facing behaviour is a contract bound to a Default* — rebind any of them in a service provider:
$this->app->bind(
\KirchDev\DeviceSessions\Contracts\IpMasker::class,
\App\Support\MyStrictIpMasker::class,
);
| Contract | Default | Controls |
|---|---|---|
DeviceResolver |
ResolveOrCreateUserDevice… |
cookie → bootstrap-cache → create flow |
DeviceNameResolver |
DefaultDeviceNameResolver |
User-Agent → "Chrome on Windows" |
OsFamilyDetector |
DefaultOsFamilyDetector |
User-Agent → DeviceOsFamily |
DeviceCookieFactory |
DeviceCookieBuilder |
device cookie name / TTL / SameSite |
IpMasker |
DefaultIpMasker |
IP minimisation (IPv4 /24, IPv6 /48) |
RememberTokenHasher |
Sha256RememberTokenHasher |
at-rest token hashing |
📡 Events & Fortify
TouchDeviceLastSeen fires DeviceTouched on a real (throttled) touch — listen instead of patching the package, e.g. to stamp your own user column:
Event::listen(fn (DeviceTouched $event) => $event->user->forceFill(['last_seen_at' => now()])->save());
Two opt-in integrations:
OtherDeviceLogout→ revokes all other devices (mirrorsAuth::logoutOtherDevices()); toggle viadevice-sessions.events.- Fortify two-factor → a listener queues the device cookie at the 2FA challenge (where the
Loginevent hasn't fired yet), auto-wired viaclass_existsso Fortify is never required. Using another 2FA flow? Write your own bridge against theDeviceResolvercontract.
🧹 Pruning revoked devices
Revoked devices are kept (audit/undo window), then pruned. The command ships unscheduled — wire it into your scheduler with Schedule::command('device-sessions:prune')->dailyAt('03:10'):
php artisan device-sessions:prune # retention from device-sessions.prune.retention_days (180)
php artisan device-sessions:prune --days=90
⚙️ Configuration
config/device-sessions.php is parameterised with inline docs — e.g. rename the cookie or switch key types:
'cookie' => ['name' => 'device', 'same_site' => 'lax'],
'keys' => ['primary_key_type' => 'id', 'user_key_type' => 'id'],
| Key | What it controls |
|---|---|
models.* |
Swap the user / device / remember_token Eloquent models. |
table_names.* |
Override defaults if they collide with existing tables. |
keys.* |
id / uuid / ulid for device PKs and the user FK. Set before migrating. |
cookie.* |
Device cookie name (default device), lifetime, SameSite, secure. |
cache.* |
Cache store, key prefix, login→2FA bootstrap TTL, last-seen throttle. |
remember.lifetime |
Minutes until a remember token expires (null = never). |
events.* |
Toggle the core event listeners. |
prune.retention_days |
Retention window for the prune command (default 180). |
🧪 Testing
composer install
composer test # Pest 4
composer pint # Laravel Pint (test mode)
composer larastan # Larastan / PHPStan
The test suite runs via Testbench + in-memory SQLite — no host app required.
🤝 Contributing
PRs welcome. Conventional Commits required (enforced via commitlint). Husky runs Pint + Larastan + oxlint + oxfmt on git commit, so you can mostly forget about style.
[!TIP] Run
pnpm check:fix(Node tooling) andcomposer pint:fix(PHP) before pushing — CI will catch what husky missed.
🛣️ Versioning
Semantic Versioning. Release notes in CHANGELOG.md — managed by release-please.