DEV Community

Kamran Khan
Kamran Khan

Posted on

A 15-line middleware and one database column: roles without the ceremony

Every SaaS needs roles. The tutorial answer is a roles table, a permissions table, two pivot tables, a seeder for each, and a policy class before you've written a single feature. For an indie-scale app with two roles — admin and user — I went the other way: one string column and a 15-line middleware. Here is the whole thing, and why it's enough.

The column

Schema::table('users', function (Blueprint $table) {
    $table->string('role')->default('user')->after('email');
});
Enter fullscreen mode Exit fullscreen mode

One string, defaulting to 'user'. role sits in the model's $fillable array, so it flows through User::create() and ->update() like any other attribute. There is no roles table because there is nothing a roles table would buy me at this scale: roles here are a fixed, closed set decided by the developer, not data the customer manages.

The decision rule I use: if the set of roles is chosen by you and changes with deploys, it's a column. If the customer creates and assigns roles at runtime, it's a table. Most starter kits and MVPs are the first kind wearing the second kind's architecture.

The middleware

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

        if (! $user || ! in_array($user->role, $roles, true)) {
            return response()->json(['message' => 'Forbidden'], 403);
        }

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

Three things worth noticing:

  1. Variadic $roles. Registered as the role alias in bootstrap/app.php, it's used as role:admin today — but role:admin,editor works tomorrow with zero code changes. The middleware doesn't know your role names; it just checks membership.
  2. Strict in_array. That third argument, true, switches off PHP's type juggling. On an authorisation boundary, strictness is not pedantry — loose comparison is exactly how surprising matches slip through.
  3. 403, not 404. A valid token with the wrong role gets {"message": "Forbidden"} and a 403. I don't hide the endpoint's existence — security through obscurity on your own admin API buys nothing and makes debugging miserable.

Order matters in the route definition:

Route::middleware(['auth:sanctum', 'role:admin'])->group(function () {
    Route::apiResource('users', UserController::class);
});
Enter fullscreen mode Exit fullscreen mode

auth:sanctum runs first, so $request->user() is populated by the time the role check executes. Swap the order and the middleware would 403 every request, including valid admins — a fun bug to chase at midnight.

The part people get wrong: who assigns roles

A role column is only safe if the assignment path is safe. Mine has three rules:

  • Registration hardcodes it. The register handler writes 'role' => 'user' directly; the request never carries a role field. A role field on the register request would be a privilege-escalation form with extra steps.
  • The seeder owns the admin. User::firstOrCreate(['email' => 'admin@demo.io'], [..., 'role' => 'admin']) — idempotent, so it can run on every deploy without duplicating anything. The first admin exists because a deploy created it, not because a form allowed it.
  • Role changes go through the admin-only endpoint. StoreUserRequest allows role: in:admin,user, and that's fine — the whole users resource sits behind role:admin. An admin promoting a user is a feature; a stranger promoting themselves is a vulnerability. Same field, different trust boundary.

When to outgrow it

I'll add a real roles/permissions system the day a customer needs to define their own roles — per-tenant roles, granular permissions like users.delete. Until then, the column is one string, the middleware is 15 lines, and there is exactly one place where "is this user allowed?" gets answered.

Small authorisation, fully understood, beats flexible authorisation nobody audited. Boring wins again.


I ship this exact setup in my Laravel + React SaaS Starter Kit ($19 one-time) — Sanctum auth, the role middleware above, and an admin users table behind it.

Top comments (0)