PHPackages                             rene-roscher/user-sessions-laravel - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Caching](/categories/caching)
4. /
5. rene-roscher/user-sessions-laravel

ActiveLibrary[Caching](/categories/caching)

rene-roscher/user-sessions-laravel
==================================

Device management, logout other devices and new-device alerts for Laravel — without giving up your cache-based session driver.

v1.0.0(2d ago)00MITPHPPHP ^8.2CI passing

Since Jul 21Pushed todayCompare

[ Source](https://github.com/Rene-Roscher/user-sessions-laravel)[ Packagist](https://packagist.org/packages/rene-roscher/user-sessions-laravel)[ Docs](https://github.com/Rene-Roscher/user-sessions-laravel)[ RSS](/packages/rene-roscher-user-sessions-laravel/feed)WikiDiscussions main Synced today

READMEChangelog (1)Dependencies (17)Versions (2)Used By (0)

User Sessions for Laravel
=========================

[](#user-sessions-for-laravel)

[![Tests](https://github.com/Rene-Roscher/user-sessions-laravel/actions/workflows/tests.yml/badge.svg)](https://github.com/Rene-Roscher/user-sessions-laravel/actions/workflows/tests.yml)[![Static analysis](https://github.com/Rene-Roscher/user-sessions-laravel/actions/workflows/static.yml/badge.svg)](https://github.com/Rene-Roscher/user-sessions-laravel/actions/workflows/static.yml)[![PHPStan](https://camo.githubusercontent.com/14995ff65edea59395c224e37e4fc66f91c1e601c1a58311e3c6f38c4fe37feb/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6c6576656c2532306d61782d627269676874677265656e)](https://phpstan.org/)[![Type Coverage](https://camo.githubusercontent.com/c0657e77de4a03f34a036f4b3936e743746ec0531ad82cd122668567e4b23c3b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f747970652d2d636f7665726167652d3130302532352d627269676874677265656e)](https://github.com/Rene-Roscher/user-sessions-laravel)[![License](https://camo.githubusercontent.com/b8cadaa967891081f8f165695470689986c028821dd8a040132f6e661795dc0d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c7565)](LICENSE.md)

> **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
------------

[](#requirements)

PHP8.2, 8.3, 8.4Laravel12.x, 13.xSession driverany (`array`, `file`, `redis`, `memcached`, …)Registry databaseanything Eloquent supports (integer, UUID and ULID user keys all work)---

Installation
------------

[](#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
-----

[](#usage)

### Show a user their devices

[](#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)

        {{ $session->device_label }}       {{-- "Chrome on macOS" --}}
        {{ $session->ip_address }}
        last active {{ $session->lastActiveHuman() }}

        @if ($session->isCurrent(request()))
            This device
        @else

                @csrf
                Log out

        @endif

@endforeach
```

### Revoke a single device

[](#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

[](#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

[](#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
------------

[](#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_sessions` table 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](tests/Feature/GuestFloodTest.php)).
- Authenticated traffic is debounced down to **one write per session per interval** — 25 requests produce a single database write ([proven by a test](tests/Feature/HotPathTest.php)).
- 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](tests/Feature/RevokeRoundtripTest.php)).
- 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 `array` or `null` store `Cache::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. Point `user-sessions.cache_store` at redis/memcached/database in production; `php artisan about` flags a store that cannot work.

---

Accurate last activity
----------------------

[](#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 driverSourcePrecision`redis` (and cache-backed drivers on a Redis store)remaining TTL — Laravel refreshes it on every writeexact`file`session file mtimeexact`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 &amp; the trait
--------------------------

[](#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, 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)
---------------------

[](#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 &amp; jobs
----------------------------

[](#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
-------------

[](#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 by [`lab404/laravel-impersonate`](https://github.com/404labfr/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
-------------

[](#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
------------------------

[](#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
------

[](#events)

EventPayloadBroadcast`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
------

[](#octane)

Guaranteed compatible, and tested for it:

- The recorder and registry are **stateless singletons** — an architecture test asserts no `src`class holds static properties or a `Request`/`Authenticatable` as a property.
- The middleware imports the namespaced `use function Illuminate\Support\defer;` (Swoole defines its own global `defer`).
- Request context is detected via `Session::isStarted()`, **not** `runningInConsole()` — the latter reports `true` under Swoole even during HTTP requests.
- A [state-bleed test](tests/Feature/StateBleedTest.php) 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 &amp; SPAs
------------------

[](#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 `200` visit records normally;
- an Inertia `303` redirect (its POST-then-redirect convention) records normally;
- an Inertia `409` asset-version conflict does **not** write a phantom row — the deferred sync skips `4xx`, 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 …?
----------

[](#why-not-)

PackageWhy 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 SessionsRequires the `database` session driver — exactly the trade-off this package removes.Non-Goals
---------

[](#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
-------

[](#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 &amp; support policy
----------------------------

[](#upgrade--support-policy)

- **Versioning:** strict [SemVer](https://semver.org) from v1.0.0. Breaking changes to the public API — the contracts in `src/Contracts`, the facade, the `HasUserSessions` trait, 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](CHANGELOG.md) — the original file is never rewritten under you.
- **Security:** report privately, see [SECURITY.md](SECURITY.md).

License
-------

[](#license)

The MIT License (MIT). See [LICENSE.md](LICENSE.md).

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance100

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity45

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 66.7% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

2d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/1fb36e30fba441ae796193c1f181cfba0489c29b2e6721227bbfe4dc8fe29bac?d=identicon)[Rene Roscher](/maintainers/Rene%20Roscher)

---

Top Contributors

[![Rene-Roscher](https://avatars.githubusercontent.com/u/43115138?v=4)](https://github.com/Rene-Roscher "Rene-Roscher (4 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (2 commits)")

---

Tags

laravelsecurityimpersonationredissessionlogoutDevices

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/rene-roscher-user-sessions-laravel/health.svg)

```
[![Health](https://phpackages.com/badges/rene-roscher-user-sessions-laravel/health.svg)](https://phpackages.com/packages/rene-roscher-user-sessions-laravel)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.3M347](/packages/psalm-plugin-laravel)[laravel/pulse

Laravel Pulse is a real-time application performance monitoring tool and dashboard for your Laravel application.

1.7k15.1M136](/packages/laravel-pulse)[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9762.4M133](/packages/roots-acorn)[aedart/athenaeum

Athenaeum is a mono repository; a collection of various PHP packages

255.2k](/packages/aedart-athenaeum)[pressbooks/pressbooks

Pressbooks is an open source book publishing tool built on a WordPress multisite platform. Pressbooks outputs books in multiple formats, including PDF, EPUB, web, and a variety of XML flavours, using a theming/templating system, driven by CSS.

45444.2k1](/packages/pressbooks-pressbooks)[api-platform/laravel

API Platform support for Laravel

58174.6k17](/packages/api-platform-laravel)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
