DEV Community

Cover image for Suspend a user in Laravel without deleting them
Nasrul Hazim
Nasrul Hazim

Posted on

Suspend a user in Laravel without deleting them

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();
Enter fullscreen mode Exit fullscreen mode
// 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();
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

$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();
});
Enter fullscreen mode Exit fullscreen mode

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)