rene-roscher / user-sessions-laravel
Device management, logout other devices and new-device alerts for Laravel — without giving up your cache-based session driver.
Package info
github.com/Rene-Roscher/user-sessions-laravel
pkg:composer/rene-roscher/user-sessions-laravel
Requires
- php: ^8.2
- illuminate/auth: ^12.0|^13.0
- illuminate/broadcasting: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/session: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-arch: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- pestphp/pest-plugin-type-coverage: ^3.0|^4.0
- phpstan/phpstan: ^2.0
- predis/predis: ^2.0
README
Device management, "log out other devices" and new-device alerts for Laravel — without giving up your cache-based session driver.
Laravel apps use Redis (or Memcached / DynamoDB) as their session driver because it's fast.
Doing so means losing everything the database driver gives you: listing active sessions,
seeing devices, invalidating a session on demand. Jetstream's Browser Sessions is hard-wired
to the database driver. This package gives you both — Redis stays the source of truth, and a
lightweight registry table gives you the device list — with zero database writes on the hot
path.
$user->sessions()->active()->get(); // the device list $request->userSession(); // the current session, as a model $session->revoke(); // log a device out — effective immediately $user->revokeOtherSessions($request); // "log out everywhere else" UserSessions::revokeAll($user); // admin-level, from anywhere
Requirements
| PHP | 8.2, 8.3, 8.4 |
| Laravel | 12.x, 13.x |
| Session driver | any (array, file, redis, memcached, …) |
| Registry database | anything Eloquent supports (integer, UUID and ULID user keys all work) |
Installation
composer require rene-roscher/user-sessions-laravel php artisan vendor:publish --tag=user-sessions-migrations php artisan migrate
Add the (optional) trait to your authenticatable model:
use ReneRoscher\UserSessions\Models\Concerns\HasUserSessions; class User extends Authenticatable { use HasUserSessions; // optional — tracking works without it }
That's it. The middleware, event listeners, request macro and log context register themselves. For automatic cleanup of stale rows, schedule Laravel's native pruning:
use Illuminate\Support\Facades\Schedule; use ReneRoscher\UserSessions\Models\UserSession; Schedule::command('model:prune', ['--model' => [UserSession::class]])->daily();
Usage
Show a user their devices
// routes/web.php or a controller $sessions = $request->user()->activeSessions();
activeSessions() is the device-list API: it returns the active rows annotated with the session
store's own view of last activity, and rows whose sessions the store has already dropped are
filtered out via isActive() — so the list reflects reality without you calling reconcile()
yourself.
{{-- resources/views/devices.blade.php --}} @foreach ($sessions as $session) <div> <strong>{{ $session->device_label }}</strong> {{-- "Chrome on macOS" --}} <span>{{ $session->ip_address }}</span> <span>last active {{ $session->lastActiveHuman() }}</span> @if ($session->isCurrent(request())) <em>This device</em> @else <form method="POST" action="/devices/{{ $session->id }}/revoke"> @csrf <button>Log out</button> </form> @endif </div> @endforeach
Revoke a single device
$request->user()->revokeSession($id); // store-destroy + revoked_at + event — effective on the next request
revokeSession() is scoped to the user's own rows, so a foreign id is a miss rather than a
cross-account revoke. It returns false if no matching row was found.
A Livewire component is just as small:
public function revoke(string $id): void { auth()->user()->revokeSession($id); }
Log out everywhere else / everywhere
$request->user()->revokeOtherSessions($request); // "log out everywhere else" $request->user()->revokeAllSessions(); // including the current one — admin-level
New-device alert
The package dispatches UserSessionCreated with an isNewDevice flag. Listen for it and
send your own notification in your app's language and branding:
use ReneRoscher\UserSessions\Events\UserSessionCreated; Event::listen(function (UserSessionCreated $event): void { if ($event->isNewDevice) { $event->session->user->notify(new NewDeviceLoginAlert($event->session)); } });
The event carries the full UserSession model, so you have device label, IP, platform and
browser without any extra queries.
How it works
Redis-primary with a shadow registry.
Request ──► Session store (Redis / Memcached / …) ← 100% source of truth
│ (hot path never touches the DB)
│ after the response, debounced
▼
user_sessions registry table ← metadata only:
(id, session_id, user, ip, device, session id, user (morph),
last_activity, revoked_at) ip, parsed user-agent, last_activity
- The session store stays the source of truth. Reading/writing a session per request never touches the database.
- A lightweight
user_sessionstable holds only metadata — never the session payload. - The registry is updated after the response (
defer()) and debounced (default: at most once every 180s per session). The debounce marker lives in the cache, never the DB. - Guests are never tracked. No
$request->user(), nothing happens — an HTTP flood produces zero database writes (proven by a test). - Authenticated traffic is debounced down to one write per session per interval — 25 requests produce a single database write (proven by a test).
- Revoking runs driver-agnostically via
Session::getHandler()->destroy($id)— it works with any session driver, not just Redis, and the next request from that device really is a guest (proven by a test). - The registry is a derivative: it may lag, fail, or be rebuilt without logging anyone out.
The debounce needs a shared, persistent cache store. On an
arrayornullstoreCache::add()cannot remember a marker between requests, so the debounce silently degrades into one database write per request — exactly what this package exists to avoid. Pointuser-sessions.cache_storeat redis/memcached/database in production;php artisan aboutflags a store that cannot work.
Accurate last activity
The registry column is written debounced, so on its own it is up to sync_interval stale, and a
session that expired or was regenerated in the store keeps sitting in the table looking alive until
it is pruned. Neither is acceptable in a device list.
So the read path asks the store, which knows exactly:
| Session driver | Source | Precision |
|---|---|---|
redis (and cache-backed drivers on a Redis store) |
remaining TTL — Laravel refreshes it on every write | exact |
file |
session file mtime | exact |
memcached, dynamodb, array, … |
not introspectable → the registry column | ±sync_interval |
This is a read-path feature only — one batched store round trip when a device list is rendered, never anything on the hot path.
$sessions = $user->activeSessions(); // resolved automatically $user->sessions->withStoreActivity(); // or resolve a relation result yourself $session->lastActivityAt(); // best known truth (store, else the column) $session->last_activity; // the raw persisted column, unchanged $session->existsInStore(); // true / false / null when the store cannot say $session->isActive(); // false as soon as the store has dropped the session
Rows whose sessions the store no longer holds can be cleaned up explicitly — never implicitly on a read:
UserSessions::reconcile($user); // marks store-dead rows revoked, returns the count
Turn the whole thing off with 'resolve_activity_from_store' => false; last_activity then behaves
as a plain debounced column (accurate to ±sync_interval).
The Facade & the trait
The HasUserSessions trait is the consumer-facing API — three methods cover the standard
device-list flow:
$user->activeSessions(); // Collection — the device list, store-accurate $user->revokeSession($id); // bool — log out one device (scoped to the user's own rows) $user->revokeOtherSessions($request); // int — "log out everywhere else" $user->revokeAllSessions(); // int — including the current one $user->currentSession($request); // ?UserSession
The raw relation is exposed for admin/audit queries — it is not the device list:
$user->sessions; // MorphMany, newest first (raw registry rows, includes revoked and store-dead)
The UserSessions facade is the admin/service layer — end a user's sessions from a Nova action,
command or controller without touching the user model:
use ReneRoscher\UserSessions\Facades\UserSessions; UserSessions::for($user); // Collection<UserSession>, store-accurate UserSessions::find($sessionId); // ?UserSession UserSessions::revoke($session, RevokedBy::ADMIN); // bool UserSessions::revokeOthers($user, $currentId); // int UserSessions::revokeAll($user); // int UserSessions::reconcile($user); // int — drop rows the store no longer holds UserSessions::payload($session); // ?array — read-only, via the handler
revokeOthers() and revokeAll() attempt every session even if one fails, and throw
CouldNotRevokeSessions when any could not be ended — a bulk revoke is a security control,
so a partial failure must never be reported to the user as "logged out everywhere".
All of these work outside a request too — from a command, a queued job or tinker. The session is genuinely destroyed in the store there as well, not merely flagged in the table.
Each UserSession model exposes:
$session->isActive(); // bool — not revoked, still in the store, within lifetime $session->lastActivityAt(); // ?CarbonImmutable — store truth where available $session->lastActiveHuman(); // ?string — "5 minutes ago" (store truth, convenience for views) $session->existsInStore(); // ?bool — null when the store cannot answer $session->isCurrent($request); // bool $session->device_label; // "Chrome on macOS" $session->revoke(); // effective immediately $session->payload(); // ?array — read-only session payload (debugging gold)
Broadcasting (opt-in)
Turn broadcast => true on and UserSessionCreated / UserSessionRevoked broadcast on a
PrivateChannel. Revoke a device on phone A and browser B flies to the login screen the same second.
// config/user-sessions.php 'broadcast' => true,
The channel is scoped by the morph type and key — user-sessions.{morphType}.{id}, where the
model's backslashes become dots (Laravel's model-channel convention) — so two different
authenticatable models that happen to share an integer id never collide. For App\Models\User #1
the channel is user-sessions.App.Models.User.1.
Register the channel authorization yourself (it's a security decision, so the package won't do it for you) — one line per authenticatable model you track:
// routes/channels.php Broadcast::channel('user-sessions.App.Models.User.{id}', function ($user, $id) { return (int) $user->getKey() === (int) $id; });
// resources/js/echo bootstrap — morphType has backslashes replaced by dots const morphType = 'App.Models.User'; Echo.private(`user-sessions.${morphType}.${userId}`) .listen('.session.revoked', (e) => window.location.reload());
Device-aware logs & jobs
Every log line and every queued job dispatched from the request carries the device context — no application code required:
'context' => [ 'enabled' => true, 'keys' => ['device', 'user_session_id'], ],
[2026-07-15 10:22:31] production.ERROR: Payment failed
{"device":"Chrome on macOS","user_session_id":"8JRSyS56DSXu…"}
device is parsed from the live request (no DB read). user_session_id is the registry row's
ULID — never the session id itself, which is a bearer credential that must not be copied into logs
and job payloads. It identifies the session for correlation and is shared without a hot-path query.
Impersonation
When an admin is impersonating a user, tracking is skipped entirely — no registry row, no events, and (crucially) no "new device login" notification is sent to the real user for the admin's device.
Impersonation is detected in two ways, out of the box:
- the native
isImpersonated()trait method (as used bylab404/laravel-impersonate), and - any of the configured session keys (default
impersonated_by, lab404's default).
Using a different package or a custom key? Add it:
// config/user-sessions.php 'impersonation' => [ 'session_keys' => ['impersonated_by', 'your_custom_key'], ],
Configuration
Publish it with php artisan vendor:publish --tag=user-sessions-config:
return [ /* Swappability — classes, not option soup */ 'model' => \ReneRoscher\UserSessions\Models\UserSession::class, 'parser' => \ReneRoscher\UserSessions\Support\NativeUserAgentParser::class, /* Read last_activity back from the session store instead of the debounced column */ 'resolve_activity_from_store' => true, 'activity_resolver' => \ReneRoscher\UserSessions\Support\StoreSessionActivityResolver::class, /* Storage */ 'table' => 'user_sessions', 'sync_interval' => 180, // seconds of debounce; 0 = sync every request (still race-safe) 'sync_on_failure' => false, // true = also sync on 4xx/5xx responses (defer()->always()) 'new_device_window' => 86400, // seconds within which a device triple counts as "known" 'prune_after_days' => 30, // retention AFTER expiry / revoke 'max_listed' => 100, // upper bound on how many sessions a device list returns 'cache_store' => null, // store for the debounce markers; null = your default store // MUST be shared and persistent — see "How it works" /* Feature toggles — each framework hook individually switchable */ 'middleware_group' => 'web', // null = register manually 'request_macro' => true, // $request->userSession() 'revoke_on_password_reset' => true, 'broadcast' => false, // opt-in 'context' => [ 'enabled' => true, 'keys' => ['device', 'user_session_id'], ], 'events' => true, // global kill-switch for package events /* Tracking is skipped entirely while impersonating (see "Impersonation") */ 'impersonation' => [ 'session_keys' => ['impersonated_by'], ], ];
Swapping implementations
Configurability comes from interfaces in the container, not config-string soup:
// A more precise user-agent parser $this->app->bind( \ReneRoscher\UserSessions\Contracts\UserAgentParser::class, MyWhichBrowserParser::class, ); // Selective tracking, a different storage strategy, … $this->app->bind( \ReneRoscher\UserSessions\Contracts\SessionRecorder::class, MyRecorder::class, );
Point model at your own subclass to rename the sessions() relation or add behaviour.
Events
| Event | Payload | Broadcast |
|---|---|---|
UserSessionCreated |
UserSession $session, bool $isNewDevice |
opt-in, user-sessions.{morphType}.{id} |
UserSessionRevoked |
UserSession $session, ?string $revokedBy |
opt-in, user-sessions.{morphType}.{id} |
UserSessionsPruned |
int $count |
no |
$revokedBy carries one of the RevokedBy constants — self, other-device, password-reset,
admin or expired (the store no longer held the session and reconcile() caught up) — so a
frontend can tell "you signed this device out" apart from "your password changed".
Applications may pass any other string.
Broadcast payloads deliberately contain the row's ULID (id) and never session_id: the
payload reaches the browser and any third-party broadcaster, and a session id is a bearer credential.
Set events => false to silence every package dispatch.
Octane
Guaranteed compatible, and tested for it:
- The recorder and registry are stateless singletons — an architecture test asserts no
srcclass holds static properties or aRequest/Authenticatableas a property. - The middleware imports the namespaced
use function Illuminate\Support\defer;(Swoole defines its own globaldefer). - Request context is detected via
Session::isStarted(), notrunningInConsole()— the latter reportstrueunder Swoole even during HTTP requests. - A state-bleed test drives requests from different users through one booted application and asserts that rows stay correctly attributed, that device context does not survive a request boundary, and that no singleton retains a request, a user or a session row.
Inertia & SPAs
Works out of the box. Inertia visits are ordinary session-backed requests, so the middleware,
the userSession() macro and the log context all behave exactly as they do for Blade responses.
The tracking write is deferred until after the response and never mutates it, so it cannot
interfere with Inertia's protocol:
- an Inertia
200visit records normally; - an Inertia
303redirect (its POST-then-redirect convention) records normally; - an Inertia
409asset-version conflict does not write a phantom row — the deferred sync skips4xx, and the full page reload Inertia triggers records instead.
The same holds for Livewire, Blade and plain JSON APIs on session-backed (web) routes.
Why not …?
| Package | Why not |
|---|---|
craftsys/laravel-redis-session-enhanced |
Metadata lives inside the Redis payload; no SQL-queryable registry, readAll doesn't scale. |
hardevine/laravel-session-tracker |
A DB write on the hot path of every request; Laravel-5-era codebase. |
diego-ninja/laravel-devices |
A 2FA / fingerprinting framework, DB-centric. |
| Jetstream Browser Sessions | Requires the database session driver — exactly the trade-off this package removes. |
Non-Goals
No 2FA. No device fingerprinting. No geo-IP. No UI components. No request logging. No writing into other sessions' payloads (a race condition by design). We integrate with the framework, not the ecosystem.
Testing
composer test # pest composer lint # pint --test composer analyse # phpstan (level max) composer check # all of the above + coverage gate
The suite runs against a session driver of your choosing, which is how the "works with any driver" claim is kept honest — CI runs all four legs:
SESSION_DRIVER=array vendor/bin/pest
SESSION_DRIVER=file vendor/bin/pest
SESSION_DRIVER=redis vendor/bin/pest # needs a redis server
SESSION_DRIVER=redis REDIS_CLIENT=predis vendor/bin/pest
The registry database is parameterised the same way, and this matters more than it looks:
SQLite does not enforce column widths, does not validate UTF-8, and implements upsert and
NOT NULL defaults differently — so a green SQLite run cannot tell you whether the package
works on the database your application actually uses. CI runs MySQL 8 and PostgreSQL 16:
DB_CONNECTION=mysql DB_PORT=33061 DB_USERNAME=root DB_PASSWORD=secret vendor/bin/pest DB_CONNECTION=pgsql DB_PORT=54321 DB_USERNAME=postgres DB_PASSWORD=secret vendor/bin/pest
Upgrade & support policy
- Versioning: strict SemVer from v1.0.0. Breaking changes to the public
API — the contracts in
src/Contracts, the facade, theHasUserSessionstrait, the model's public methods, event payloads and config keys — only ever land in a major release. - Not public API: anything marked
@internal, the exact SQL emitted, and the shape of log messages. These may change in a minor release. - Supported branches: the current major receives features and fixes; the previous major receives security fixes for six months after the new major is tagged.
- Laravel versions: a Laravel release is supported while it receives official bug fixes. Dropping one is a major release for this package.
- Migrations: the shipped migration is published into your app, so it is yours. Schema changes in later versions ship as additional migrations with upgrade notes in the CHANGELOG — the original file is never rewritten under you.
- Security: report privately, see SECURITY.md.
License
The MIT License (MIT). See LICENSE.md.