TL;DR
- Suspension is not deletion: keep the row, block the sign-in.
- Model it as a status enum, enforce it with middleware on every authenticated request, gate the toggle with a policy.
- The bugs live in the edges: an already-authenticated session, and an admin suspending themselves.
I needed the same feature in two apps: an admin should be able to suspend a user — block them from signing in — without deleting the account. Soft-deleting is wrong here (it hides the record and cascades relationships); a boolean is_active is too thin once you want a reason or a timestamp. Here's the shape I landed on.
1. Model the state, not a flag
A nullable suspended_at timestamp carries more than a boolean — it tells you when, which you'll want for the audit trail and for the "suspended 3 days ago" UI.
// migration
$table->timestamp('suspended_at')->nullable();
// User.php
public function isSuspended(): bool
{
return $this->suspended_at !== null;
}
public function suspend(): void
{
$this->forceFill(['suspended_at' => now()])->save();
}
public function reinstate(): void
{
$this->forceFill(['suspended_at' => null])->save();
}
If you already lean on an enum for user status (UserStatus::Active, UserStatus::Suspended) with label() and color(), derive the badge from that — but keep the timestamp as the source of truth so you don't lose when.
2. Enforce on every request, not just at login
The obvious spot is the login flow. But that only stops new sign-ins — a user suspended while already logged in keeps their session. So the check belongs in middleware on authenticated routes, not just the auth attempt.
class EnsureUserIsNotSuspended
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user()?->isSuspended()) {
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')
->withErrors(['email' => 'Your account has been suspended.']);
}
return $next($request);
}
}
Register it in the authenticated group so it runs on the very next request after suspension — the session dies mid-flight, not at some future login.
3. Gate the toggle with a policy — including "not yourself"
Two rules: only an admin may suspend, and nobody suspends themselves (an admin locking themselves out is a support ticket waiting to happen).
public function suspend(User $actor, User $target): bool
{
return $actor->isAdmin() && $actor->isNot($target);
}
$actor->isNot($target) is the whole edge case in one line — it's the bug you'd otherwise find in production.
4. Test the edges, not the happy path
The happy path barely needs a test. The edges do: a live session getting kicked, and self-suspension being refused.
it('kicks a user whose account is suspended mid-session', function () {
$user = User::factory()->create();
$this->actingAs($user)->get('/dashboard')->assertOk();
$user->suspend();
$this->get('/dashboard')->assertRedirect('/login');
$this->assertGuest();
});
it('forbids an admin from suspending themselves', function () {
$admin = User::factory()->admin()->create();
expect($admin->can('suspend', $admin))->toBeFalse();
});
Takeaway
Suspension is three small pieces — a timestamp, a middleware, a policy — and one insight: enforce it on every request, because the user you most want to stop might already be inside. Model the state richly enough to answer "when?", gate the action so an admin can't lock themselves out, and let the tests live where the bugs do.
Top comments (0)