Building a "Login As" User Impersonation Feature for Support Admins
"I click the button and nothing happens" is a lot easier to debug when you can actually see the account it's happening on. This tutorial builds a "login as" feature that lets support staff at PHP Architect temporarily view the app as a specific customer, safely and with a full audit trail.
Step 1: Track who's impersonating whom
Schema::create('impersonation_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('admin_id')->constrained('users');
$table->foreignId('target_id')->constrained('users');
$table->timestamp('started_at');
$table->timestamp('ended_at')->nullable();
$table->timestamps();
});
Never skip the audit log on a feature like this. If a customer asks "who was in my account on Tuesday," you need a real answer.
Step 2: The impersonation service
namespace App\Services;
use App\Models\ImpersonationLog;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
class ImpersonationService
{
public function start(User $admin, User $target): void
{
abort_if($target->is_admin, 403, 'Cannot impersonate an admin account.');
Session::put('impersonator_id', $admin->id);
$log = ImpersonationLog::create([
'admin_id' => $admin->id,
'target_id' => $target->id,
'started_at' => now(),
]);
Session::put('impersonation_log_id', $log->id);
Auth::login($target);
}
public function stop(): void
{
$adminId = Session::pull('impersonator_id');
$logId = Session::pull('impersonation_log_id');
if (! $adminId) {
return;
}
ImpersonationLog::whereKey($logId)->update(['ended_at' => now()]);
Auth::login(User::findOrFail($adminId));
}
public function isImpersonating(): bool
{
return Session::has('impersonator_id');
}
}
Storing the admin's ID in the session — rather than swapping auth guards — keeps this simple: Auth::login() on the target account does the heavy lifting, and stop() logs straight back in as the original admin.
Step 3: Routes and controller
Route::middleware(['auth', 'can:impersonate,App\Models\User'])->group(function () {
Route::post('/admin/users/{user}/impersonate', [ImpersonationController::class, 'start']);
Route::post('/admin/stop-impersonating', [ImpersonationController::class, 'stop']);
});
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\ImpersonationService;
class ImpersonationController extends Controller
{
public function __construct(private ImpersonationService $impersonation) {}
public function start(User $user)
{
$this->impersonation->start(auth()->user(), $user);
return redirect()->route('dashboard')
->with('status', "Now viewing as {$user->name}.");
}
public function stop()
{
$this->impersonation->stop();
return redirect()->route('admin.users.index')
->with('status', 'Impersonation ended.');
}
}
The can:impersonate gate is doing real work here — never let this route be reachable by anything less than a tightly scoped permission.
Step 4: A visible banner, always
The single most important UX rule for impersonation: the person impersonating must never forget they're doing it, and it must be obvious how to stop.
@if(app(App\Services\ImpersonationService::class)->isImpersonating())
<div class="bg-amber-500 text-black text-center py-2 font-semibold">
You are viewing as {{ auth()->user()->name }}.
<form action="{{ route('admin.stop-impersonating') }}" method="POST" class="inline">
@csrf
<button type="submit" class="underline">Stop impersonating</button>
</form>
</div>
@endif
Guardrails worth keeping
A few things this feature should never do: allow impersonating another admin, allow impersonation to touch billing or account-deletion actions (gate those separately, checking Session::has('impersonator_id') and blocking if true), or let a session silently expire back into the admin's own account without ending the log entry. A scheduled command that closes any impersonation_logs row still open after, say, four hours is cheap insurance against a support agent forgetting to click "stop."
Done well, impersonation turns a fifteen-message support thread into a two-minute screen-share-with-yourself. Done carelessly, it's a serious trust and security problem — so keep the audit trail, the banner, and the admin-blocking gate non-negotiable.