smony/filament-user-sessions
Filament plugin that lists every active user session—user, IP, parsed device and browser, last activity—with one-click and bulk revoke to force-logout anyone. Flags logins from new devices. Requires Laravel's database session driver.
Pre-flight check — this package only works under one session driver. Before installing, confirm config/session.php resolves to SESSION_DRIVER=database. The package reads Laravel's sessions table directly; file, cookie, redis, array, and memcached drivers all result in a plugin that installs cleanly and then shows an empty table with no error.
composer require smony/filament-user-sessions
# Only if you don't already have a sessions migration
php artisan session:table
php artisan migrate
The migrate step matters twice: it creates/updates the sessions table and runs the package migration for session_known_devices. If you skip it, the Sessions page still renders but the "New device" flagging silently never works.
Register the plugin in the panel provider you actually administer from:
use Smony\FilamentUserSessions\FilamentUserSessionsPlugin;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(FilamentUserSessionsPlugin::make());
}
Version pinning is not optional. v2.x supports Filament ^4.0|^5.0; Filament v3 users must install smony/filament-user-sessions:^1.0. Installing v2 on a v3 app fails at the Filament\Resources\Resource type level (the BackedEnum/UnitEnum union on $navigationIcon/$navigationGroup fixed in v2.0.1).
First use case, in order:
Where to look first:
config/filament-user-sessions.php — publish it immediately (php artisan vendor:publish --tag=filament-user-sessions-config), even if you change nothing. Reading the real file beats guessing option names from the README.Smony\FilamentUserSessions\Models\Session — this is the model your policy targets and the anchor for everything authorization-related.Smony\FilamentUserSessions\Policies\SessionPolicy — read the default before overriding it; it tells you exactly which abilities the page calls.Smony\FilamentUserSessions\Contracts\ResolvesNewDeviceRecipients — the only real integration seam if you want alerting.FilamentUserSessionsPlugin — check for chained configuration methods before reaching for config file edits.The default policy is fully permissive: any user who can reach the panel can see every session in the app and revoke any of them. In a support-role or multi-tenant panel this is a privilege-escalation surface — a junior admin can kick out a superadmin mid-incident.
Override it in a dedicated service provider (app providers boot after package providers, so your registration wins):
use Illuminate\Support\Facades\Gate;
use Smony\FilamentUserSessions\Models\Session;
public function boot(): void
{
Gate::policy(Session::class, \App\Policies\SessionPolicy::class);
}
class SessionPolicy
{
public function viewAny(User $user): bool
{
return $user->hasAnyRole(['superadmin', 'security']);
}
public function view(User $user, Session $session): bool
{
return $user->isSuperAdmin() || $user->tenant_id === $session->user?->tenant_id;
}
public function delete(User $user, Session $session): bool
{
return $user->isSuperAdmin() || $session->user_id === $user->id;
}
public function deleteAny(User $user): bool
{
return $user->isSuperAdmin();
}
}
Two things people get wrong here: (a) implementing delete but not deleteAny and then wondering why bulk revoke bypasses their restriction, and (b) registering the policy inside the panel provider instead of a booted service provider, which can lose the race against the package's own registration.
The realistic day-to-day use is a compromise or an offboarding, not idle browsing:
new_device_window_hours. That's your starting suspect, not your conclusion (see gotchas).A useful habit: keep a lightweight audit trail yourself. The package revokes by deleting session rows, so there is no built-in "who revoked whom" record. If you need that for compliance, wrap it in your own Filament action or listen for the deletion on the Session model.
This is entirely opt-in and a complete no-op until you implement the contract — a fresh install sends nothing.
use Illuminate\Contracts\Auth\Authenticatable;
use Smony\FilamentUserSessions\Contracts\ResolvesNewDeviceRecipients;
class SecurityTeamRecipients implements ResolvesNewDeviceRecipients
{
public function resolve(Authenticatable $user): iterable
{
return User::where('is_superadmin', true)->get();
}
}
// config/filament-user-sessions.php
'new_device_notification_recipients' => \App\Support\SecurityTeamRecipients::class,
'new_device_notification_channels' => ['mail', 'database'],
'notify_on_new_device' => true,
'new_device_window_hours' => 24,
Note the README's own example has a mismatch: the class is defined as SecurityTeamRecipients but the config points at \App\Notifications\SecurityTeamRecipients::class. Decide on one namespace and be consistent — a wrong FQCN here fails silently rather than loudly.
Practical framing: this is a per-user, per-fingerprint, first-sighting-only alert. It fires when a user's session appears with a browser/OS combination never recorded for them, and never again for that combination. That means it's excellent for "Alice logged in from a Windows machine for the first time ever" and useless for "attacker logged in from Chrome on Windows when Alice already uses Chrome on Windows."
database channel requires php artisan notifications:table and recipient models using Notifiable, otherwise the notification is created and discarded (or throws, depending on your queue setup).
The plugin is a window onto the sessions table — nothing more. In an app with multiple guards:
sessions.user_id show up with a user attached.user_id is null (guest/remember-me-only, some custom guards) render as orphan rows you can still revoke but can't attribute.If you renamed the sessions table, change it in both places — Laravel's session.table and the package's own config key — plus make sure your migration created it with the standard columns (id, user_id, ip_address, user_agent, payload, last_activity). A mismatch produces an empty, error-free list.
For feature tests that touch this page, set SESSION_DRIVER=database in that test's environment and seed a row manually or via an actual login. Do not rely on the default phpunit.xml — see below.
phpunit.xml will lie to you. Laravel's default test config sets SESSION_DRIVER=array. Every test that visits the Sessions page will find zero sessions and your "revoke logs the user out" test will pass vacuously or fail confusingly. Override it explicitly in the test that cares.
Stale rows accumulate. Revoking deletes rows, but expired sessions are only removed by Laravel's probabilistic session garbage collection, which depends on the GC lottery config and your session lifetime. On a busy or long-lifetime app the table grows and the Sessions page fills with rows for browsers that closed weeks ago. The "Online now" widget uses its own configurable threshold — keep that threshold at or below your real session lifetime, or it will count dead sessions as online. Use bulk revoke as periodic hygiene, and consider a scheduled prune of your own if the table is large.
The user_id column. Laravel's default sessions migration includes user_id, but if you're on a hand-rolled or very old migration you may not have it. The plugin then shows sessions with no user, and per-user revocation becomes guesswork. Verify the column exists before assuming the package is broken.
Your own session is unreachable. The single revoke action is disabled for the current session, and since v2.0.2 bulk revoke excludes it too. If you're testing "log myself out," that's not the mechanism. Also note: revoking a row kills the session, but a user with a valid "remember me" cookie may be transparently re-authenticated on their next visit — force-logout is not a substitute for a password reset.
The "New" flag is a sniff test, not a security control. The README is explicit and it's worth repeating to whoever reads your dashboards: the fingerprint is browser + platform only, with no IP component (deliberately, to avoid VPN/mobile false positives). Two different physical machines running Chrome on Windows look identical — same fingerprint, so the second one is not flagged. Conversely, a user legitimately trying Edge once gets flagged. Treat the flag as "worth a second look," never as evidence.
Wiped known devices make everything new. session_known_devices persists across logout, but if it's truncated (a migrate:fresh in staging, a bad rollback, a cleanup script) every user's current device looks brand new and your security team gets a mail storm. If you ever truncate it, disable notify_on_new_device first.
new_device_window_hours is a lookback, not a lifetime. A session is flagged if its fingerprint was first seen within the window. After 24 hours a genuinely suspicious session quietly stops being flagged — so an alert that arrives by email is the durable signal, and the table flag is only useful if you look promptly.
Config changes need php artisan config:clear. Publishing the config then editing new_device_window_hours and seeing no effect is almost always config caching, not a bug.
Don't re-run php artisan session:table on an existing app. It scaffolds a new migration; if you already have one, you'll hit a duplicate-table error on the next migrate.
Bulk revoke across tenants is one click. Because the default policy is permissive and the sessions table is global, a multi-tenant panel exposes cross-tenant sessions in one list. Scope viewAny and view by tenant before shipping — this is the single highest-risk default in the package.
Upgrading from v1 → v2 requires a Filament major upgrade. There's no v2 release for Filament v3. Plan that as part of the Filament upgrade, not as a standalone composer bump. Also read the v2.3.0 note: it adds session_known_devices, so existing installs need php artisan migrate — behaviour is otherwise unchanged until you opt into the notification config.
Extension points, ranked by usefulness: (1) your own SessionPolicy — do this on day one; (2) ResolvesNewDeviceRecipients — do this if you have a security contact; (3) config publishing for window/threshold tuning; (4) deleting Session models yourself from a custom Filament action if you need audit logging around revocation.
Privacy. The page renders IP addresses and user agents — personal data in most jurisdictions. If your panel is accessed by contractors or has broad read access, that's a data-protection decision, not just an authorization one.
How can I help you explore Laravel packages today?