Skip to content
Packstub

Configuration

Settings that fit in config live in config/packstub-account-switcher.php. Rules that need code go on the app context, from a service provider's boot() method.

The config file

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

    // The session guard a switch signs in on. null = the default guard.
    'guard' => null,

    // The connection both tables live on. null = the user model's connection.
    'connection' => null,

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

    'features' => [
        'impersonation' => true,
        'linked_accounts' => true,
        'developer_logins' => false,
    ],

    // After a switch. null = the home page.
    'redirect_to' => null,

    // After an impersonation ends and the impersonator is gone.
    // null = the `login` route when there is one, else the home page.
    'login_url' => null,

    // Write an account_switches row for every switch. Off, together with
    // features.linked_accounts, the package runs without its migrations.
    'log_switches' => true,

    'developer_logins' => [
        // Hard ceiling; nothing else can override it.
        'environments' => ['local'],
        // E-mails, or null for the first ten users.
        'users' => null,
    ],

    '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.

Connection

Both tables follow the user model's connection, so they always sit next to the users table. That is what a multi-database tenancy setup needs: a user model on a central connection keeps linked accounts and the switch log central, even while a request runs on a tenant connection. Set connection to pin them somewhere else. The migrations honour the same setting.

Redirects

After a switch the browser goes to redirectTo() on the app context if set, else redirect_to from the config, else the home page. A closure receives the user who was just signed in and the SwitchReason, so one rule can send an impersonation and a developer login to different places. Developer logins may have a landing page of their own, developerLoginRedirectTo(); see Developer logins.

Landing on a hidden page

The page the browser lands on after a switch may be one the new account cannot see. Give the app context a fallback and a 403 or 404 on that first page is answered with a redirect to it, once; later 403s and 404s are shown as usual:

$this->app->make(AppContext::class)
    ->fallbackTo(fn (User $user): string => route('dashboard'));

Off until set. The switch flashes the URL in the session (AccountSwitcher::FALLBACK_URL_SESSION_KEY) and the package's exception handler hook reads it on the next request.

The app context

Packstub\AccountSwitcher\AppContext is where code-level rules go. It is a singleton; configure it in a service provider:

use Packstub\AccountSwitcher\AppContext;

public function boot(): void
{
    $this->app->make(AppContext::class)
        // Extra rules, evaluated after canImpersonate() / canBeImpersonated() on the models
        ->canImpersonateUsing(fn (User $by, User $target): bool => $by->can('impersonate', $target))
        ->canSwitchUsing(fn (User $from, User $to): bool => ! $to->is_locked)

        // Whether an account may sign in here at all (a linked account that may not is never offered)
        ->canAccessUsing(fn (User $user): bool => $user->is_active)

        // Where to land after a switch (string or Closure(Authenticatable, ?SwitchReason): string)
        ->redirectTo(fn (User $user, ?SwitchReason $reason): string => route('dashboard'))

        // Where a 403 or 404 landing page is sent, once (off until set)
        ->fallbackTo(fn (User $user): string => route('dashboard'))

        // How users are named in the banner and the developer login buttons
        ->userLabelUsing(fn (User $user): string => $user->full_name)

        // The developer login accounts (a closure returning users, e-mails, or null for the first ten)
        ->developerLoginUsersUsing(fn () => User::query()->where('role', 'developer')->get())

        // Developer logins: a runtime gate, labels, tags, a search beyond the list, their own landing page
        ->developerLoginsWhen(fn (): bool => app()->environment('local') || auth()->check())
        ->developerLoginLabelUsing(fn (User $user): string => "{$user->role} ({$user->username})")
        ->developerLoginGroupUsing(fn (User $user): ?string => $user->role)
        ->developerLoginSearchUsing(fn (string $search) => User::query()->whereLike('email', "%{$search}%", caseSensitive: false)->limit(20)->get())
        ->developerLoginRedirectTo('/app')

        // Extra data for every switch-log row
        ->switchLogContextUsing(fn (SwitchReason $reason): array => ['tenant' => tenant('id')]);
}

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 Where it happened: the context's scope (null in a plain app, the panel id in Filament)
guard The guard the session was signed in on
ip_address, user_agent From the request
context Extra data you attach (JSON, nullable), see below
created_at When

Query it with the Packstub\AccountSwitcher\Models\AccountSwitch model (fromUser() / toUser() relationships) for an "Account activity" page. Turn logging off with log_switches.

Context

Attach what the row cannot infer: the tenant, a ticket number, the client app. App-wide, from a service provider:

use Packstub\AccountSwitcher\AccountSwitcher;

AccountSwitcher::resolveLogContextUsing(fn (SwitchReason $reason): array => [
    'tenant' => tenant()?->getTenantKey(),
]);

The context's own data is merged over it (switchLogContextUsing() on the app context, ->switchLogContextUsing() on a Filament panel's plugin), and what you pass to log() over both.

Installs that predate the column get it from the guarded add_context_to_account_switches_table migration: php artisan vendor:publish --tag="account-switcher-migrations", then php artisan migrate.

Switches made elsewhere

Some switches never go through the package: a tenancy package's impersonation token redeemed on another domain, an SSO hand-off. Log them in the same table so the audit trail stays complete:

use Packstub\AccountSwitcher\Facades\AccountSwitcher;

AccountSwitcher::log(from: $centralUser, to: $tenantUser, reason: SwitchReason::Impersonation, context: ['tenant' => $tenant->id]);

It honours log_switches and returns the row, or null when logging is off.

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
routes($prefix, $middleware) Register the four routes
canImpersonate($by, $target) / impersonate($target) / isImpersonating() / impersonator() / stopImpersonating() Impersonation
canSwitchToLinkedAccount($from, $to) / requiresPassword($from, $to) / switchToLinkedAccount($to, $password) / switchableAccounts($user) Linked accounts
passwordMatches($account, $password) Verify a plain-text password against an account
developerLoginsEnabled() / developerLoginUsers() / searchDeveloperLogins($search) / isDeveloperLoginAllowed($user, $search) / developerLogin($user, $search) / developerLoginRedirectUrl($user) Developer logins
redirectUrlFor($user, $reason) / redirectWhenPageIsInaccessible($exception, $request) Where to land after a switch; the landing-page fallback
log($from, $to, $reason, $context) / AccountSwitcher::resolveLogContextUsing($callback) The switch log
context() The bound SwitchContext
userModel() / connectionName() / resolveUsers($users) The resolved user model class, the tables' connection, users from a closure or an e-mail list

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

Your own context

Everything that depends on where a switch happens is behind the Packstub\AccountSwitcher\Contracts\SwitchContext contract: the guard, the scope written to the log, user labels, access, redirects and the landing-page fallback, the feature toggles, the extra rules, the developer login list with its gate, labels, tags, search and landing page, the log context, and the URLs the two components post to. The package binds AppContext; the Filament package binds a context that answers from the current panel. Bind your own when your app has several places to sign in, and build it on AppContext: a method added to the contract in a minor then comes with its default.

use Packstub\AccountSwitcher\AppContext;
use Packstub\AccountSwitcher\Contracts\SwitchContext;

class SupportConsoleContext extends AppContext
{
    public function scope(): ?string
    {
        return 'support-console';
    }

    public function guardName(): string
    {
        return 'staff';
    }
}

$this->app->singleton(SwitchContext::class, SupportConsoleContext::class);

Translations and views

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

Strings live under lang/vendor/account-switcher/{locale}/messages.php (banner, developer logins heading, reasons, errors); views under resources/views/vendor/account-switcher/components/ (banner, developer-logins).