Packstub.

Configuration

Most settings live on the plugin so each panel can differ; a small config file holds the app-wide ones (tables, user model, environments).

The plugin API

use Filament\View\PanelsRenderHook;
use Packstub\AccountSwitcher\AccountSwitcherPlugin;

AccountSwitcherPlugin::make()
    // Feature toggles (bool or Closure)
    ->impersonation(true)
    ->linkedAccounts(true, userMenuItem: true)
    ->developerLogins(['admin@example.com'])

    // Authorization hooks
    ->canImpersonateUsing(fn (User $by, User $target): bool => $by->can('impersonate', $target))
    ->canSwitchUsing(fn (User $from, User $to): bool => ! $to->is_locked)

    // Sub-account creation
    ->createSubAccountUsing(fn (array $data, User $owner): User => User::create($data))

    // Where to land after any switch (string or Closure(Authenticatable): string)
    ->redirectTo(fn (User $user): string => $user->is_admin ? '/admin' : '/app')

    // Audit log on/off for this panel (defaults to the config value)
    ->logSwitches(true)

    // UI placement
    ->switcherRenderHook(PanelsRenderHook::USER_MENU_BEFORE)
    ->banner(position: 'bottom', style: 'dark')
    ->linkedAccountsPage(App\Filament\Pages\LinkedAccounts::class)

Banner placement

banner(position: 'top', style: 'light') renders the impersonation banner at the top of the page in the light style; the default is a dark banner at the bottom.

The impersonation banner at the top of the page in the light style

Feature toggles

Method Default Effect when off
impersonation() on ImpersonateAction hidden, switch-back route not registered, banner not rendered, impersonate() throws
linkedAccounts() on No "Switch to" menu, no Linked accounts page or user menu item, switchToLinkedAccount() throws
developerLogins() off No buttons, developer-login route not registered

Each accepts a Closure for runtime decisions, e.g. ->impersonation(fn (): bool => config('features.impersonation')).

Redirects

After a switch the plugin sends the browser to:

  1. redirectTo() if set (string, or a closure receiving the target user), else
  2. the current panel if the target user may access it, else
  3. the first panel the target user may access, else
  4. the current panel's URL (Filament will then show 403).

"May access" means canAccessPanel() for FilamentUser models, or "local environment" for other models — the same rule Filament's own Authenticate middleware applies.

The config file

php artisan vendor:publish --tag="packstub-account-switcher-config"
return [
    // The model linked accounts and the switch log point to.
    // null = config('auth.providers.users.model').
    'user_model' => null,

    'tables' => [
        'linked_accounts' => 'linked_accounts',
        'account_switches' => 'account_switches',
    ],

    // Write an account_switches row for every switch.
    'log_switches' => true,

    'developer_logins' => [
        // Hard ceiling; the plugin toggle cannot override it.
        'environments' => ['local'],
    ],

    'banner' => [
        'position' => 'bottom', // 'top' | 'bottom'
        'style' => 'dark',      // 'dark' | 'light'
    ],
];

User model

Linked accounts and the switch log are relationships on one user model. It is resolved from user_model, falling back to auth.providers.users.model. Set it before running the migrations — they derive the foreign keys and the referenced table from it.

The switch log

Every switch writes one row to account_switches:

Column Meaning
from_user_id Who was signed in (null for a developer login from the login page)
to_user_id Who the session became
reason impersonation, impersonation_ended, linked_account, developer_login (SwitchReason enum)
panel, guard Where it happened
ip_address, user_agent From the request
created_at When

Query it with the Packstub\AccountSwitcher\Models\AccountSwitch model (fromUser() / toUser() relationships), or build a Filament resource on it for an "Account activity" page. Turn logging off globally with log_switches or per panel with ->logSwitches(false).

Events

Event When Payload
Packstub\AccountSwitcher\Events\AccountSwitching After authorization passed, before the session changes. Throw to abort. ?Authenticatable $from, Authenticatable $to, SwitchReason $reason
Packstub\AccountSwitcher\Events\AccountSwitched After the session changed and the log row was written same
use Packstub\AccountSwitcher\Events\AccountSwitched;

Event::listen(AccountSwitched::class, function (AccountSwitched $event): void {
    if ($event->reason === SwitchReason::Impersonation && $event->to->is_vip) {
        Notification::route('mail', 'security@example.com')->notify(new VipImpersonated($event));
    }
});

The facade

Packstub\AccountSwitcher\Facades\AccountSwitcher proxies the AccountSwitcher service:

Method Purpose
canImpersonate($by, $target) / impersonate($target) / isImpersonating() / impersonator() / stopImpersonating() Impersonation
canSwitchToLinkedAccount($from, $to) / requiresPassword($from, $to) / switchToLinkedAccount($to, $password) Linked accounts
developerLoginsEnabled() / developerLoginUsers() / developerLogin($user) Developer logins
redirectUrlFor($user) / accessiblePanel($user) Panel resolution
plugin() / userModel() Current panel's plugin instance; resolved user model class

Denied operations throw Packstub\AccountSwitcher\Exceptions\AccountSwitchDenied (an AuthorizationException).

Translations and views

php artisan vendor:publish --tag="packstub-account-switcher-translations"
php artisan vendor:publish --tag="packstub-account-switcher-views"

Strings live under lang/vendor/packstub-account-switcher/{locale}/account-switcher.php; views under resources/views/vendor/packstub-account-switcher/ (menu, banner, developer-logins, pages/linked-accounts).