DEV Community

Cover image for One Nullable Timestamp, Four Account States: Deriving User Status in Laravel
Nasrul Hazim Bin Mohamad
Nasrul Hazim Bin Mohamad

Posted on

One Nullable Timestamp, Four Account States: Deriving User Status in Laravel

Most of today went into a user-management overhaul in kickoff — my Laravel starter kit. Flyout CRUD panels, bulk actions, permission assignment, and the piece I want to talk about: account status. Active, suspended, unverified, deleted.

The interesting part isn't the feature. It's the modelling decision underneath it. Where do those four states actually live?

The trap: a status column

The obvious move is a status enum column on users. Set it to suspended when you suspend someone, active when you reinstate, unverified until they verify their email, deleted when they're soft-deleted.

It works. Until it doesn't. Now you've got a status column and an email_verified_at column and a deleted_at from soft deletes — and all three encode overlapping truths. Soft-delete a user and forget to flip status? Now your database says the account is both active and trashed. Verify an email but the status update fails mid-request? Drift. Every place that mutates a user becomes a place that has to remember to keep status in sync. That's not a feature, that's a maintenance tax.

The signals already exist. email_verified_at tells you verified-or-not. deleted_at (soft deletes) tells you removed-or-not. The only genuinely new state is suspended — an admin deliberately blocking sign-in. So that's the only thing worth storing.

What we actually store: one nullable timestamp

Schema::table('users', function (Blueprint $table) {
    $table->timestamp('suspended_at')->nullable()->after('email_verified_at');
});
Enter fullscreen mode Exit fullscreen mode

That's the whole migration. Not a boolean is_suspended — a nullable timestamp. Null means not suspended; a value means suspended and tells you when. A boolean throws that second fact away for free; the timestamp keeps it at no extra cost. Same instinct as email_verified_at and deleted_at — Laravel models "did this happen, and when" as a nullable timestamp everywhere, so we follow the grain of the framework.

The behaviour on the model stays tiny:

public function isSuspended(): bool
{
    return $this->suspended_at !== null;
}

public function suspend(): void
{
    $this->forceFill(['suspended_at' => now()])->save();
}

public function unsuspend(): void
{
    $this->forceFill(['suspended_at' => null])->save();
}
Enter fullscreen mode Exit fullscreen mode

Status is derived, never stored

Here's the move. status() isn't a column read — it's a match over the signals, in priority order:

public function status(): UserStatus
{
    return match (true) {
        $this->trashed()                   => UserStatus::DELETED,
        $this->isSuspended()               => UserStatus::SUSPENDED,
        $this->email_verified_at === null  => UserStatus::UNVERIFIED,
        default                            => UserStatus::ACTIVE,
    };
}
Enter fullscreen mode Exit fullscreen mode

match (true) reads like a cond — the first arm whose condition is truthy wins, so order encodes precedence. Deleted beats suspended beats unverified beats active. A trashed-and-suspended user reads as DELETED, which is what you want: the strongest fact wins, and there's exactly one place that decides. No drift, because there's nothing to keep in sync — the status is computed fresh from columns that other parts of Laravel are already maintaining for you.

Querying gets the same treatment via scopes, so "active" means the same thing in a list query as it does on a single model:

public function scopeActive(Builder $query): Builder
{
    return $query->whereNull('suspended_at')->whereNotNull('email_verified_at');
}

public function scopeSuspended(Builder $query): Builder
{
    return $query->whereNotNull('suspended_at');
}
Enter fullscreen mode Exit fullscreen mode

The enum carries its own presentation

UserStatus is a string-backed enum, but it implements a Contract and pulls in an InteractsWithEnum trait (from my traitify package) so every enum in the app exposes the same label() / color() / description() surface:

enum UserStatus: string implements Contract
{
    use InteractsWithEnum;

    case ACTIVE = 'active';
    case SUSPENDED = 'suspended';
    case UNVERIFIED = 'unverified';
    case DELETED = 'deleted';

    public function color(): string
    {
        return match ($this) {
            self::ACTIVE     => 'green',
            self::SUSPENDED  => 'amber',
            self::UNVERIFIED => 'zinc',
            self::DELETED    => 'red',
        };
    }

    // label() and description() follow the same match shape
}
Enter fullscreen mode Exit fullscreen mode

The payoff is the view never branches on status. It asks the enum:

<flux:badge :color="$user->status()->color()">
    {{ $user->status()->label() }}
</flux:badge>
Enter fullscreen mode Exit fullscreen mode

Add a BANNED case later and you touch exactly one file — the enum — not every Blade template that paints a badge. The presentation lives with the data it describes, which is the whole point of giving an enum methods instead of scattering match blocks across the UI.

Deriving the state isn't enforcing it

A computed status() is a label. It does not stop a suspended user from using an existing session — they were logged in before you suspended them, and the cookie doesn't care about your enum. Enforcement is a separate, deliberate boundary: middleware.

class EnsureUserIsNotSuspended
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        if ($user && $user->isSuspended()) {
            Auth::guard('web')->logout();

            $request->session()->invalidate();
            $request->session()->regenerateToken();

            return redirect()
                ->route('login')
                ->with('error', __('Your account has been suspended. Please contact the administrator.'));
        }

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Note it doesn't just redirect — it logout()s, invalidates the session, and regenerates the CSRF token. Suspension should evict, not merely inconvenience. A redirect alone leaves a valid session sitting in the cookie jar.

Pest: pin the precedence and the eviction

Two things are worth locking down — that the match precedence holds, and that the middleware actually kicks a suspended session out.

it('derives status with deleted taking precedence over suspended', function () {
    $user = User::factory()->create(['email_verified_at' => now()]);

    expect($user->status())->toBe(UserStatus::ACTIVE);

    $user->suspend();
    expect($user->fresh()->status())->toBe(UserStatus::SUSPENDED);

    $user->delete(); // soft delete
    expect($user->fresh()->status())->toBe(UserStatus::DELETED);
});

it('evicts a suspended user mid-session', function () {
    $user = User::factory()->create(['email_verified_at' => now()]);
    $user->suspend();

    $this->actingAs($user)
        ->get('/dashboard')
        ->assertRedirect(route('login'));

    expect(auth()->check())->toBeFalse();
});
Enter fullscreen mode Exit fullscreen mode

The first test is the one that earns its keep over time: it freezes the precedence order so a future refactor can't quietly let SUSPENDED outrank DELETED.

Takeaway

Don't store what you can derive. A status column looks convenient and turns into four columns that disagree with each other. Keep one nullable timestamp for the only state nobody else tracks (suspended_at), lean on email_verified_at and deleted_at for the rest, and compute status() with an ordered match (true) so precedence is explicit and there's a single source of truth.

Then remember the part that's easy to skip: deriving a state and enforcing it are different jobs. The enum labels the account; the middleware is what actually shows a suspended user the door.

Top comments (0)