RBAC: Roles, Permissions, and Authorization Middleware

Model roles, permissions, and the role-permission mapping that connects them, then build authorization middleware in PHP that blocks a request before it reaches a controller unless the current user holds the required permission.

Authentication answers 'who are you?' -- authorization answers 'what are you allowed to do?'. Role-Based Access Control (RBAC) is the most common way production apps answer the second question: instead of asking 'is this specific user allowed to delete posts?' for every one of your thousands of users, you group users into roles (editor, moderator, admin), attach permissions to those roles (posts.delete, users.ban, billing.refund), and then ask the much simpler question 'does this user's role include this permission?'. This lesson builds a real roles/permissions/role-permission-mapping data model in PHP and wires it into authorization middleware that runs before every protected request.

Why Group Permissions Into Roles?

Imagine an app with 40 distinct actions a user might perform -- create a post, delete a comment, ban a user, issue a refund, view analytics -- and 10,000 users. Assigning permissions directly to each user means a table with up to 400,000 rows, and every new hire on the support team requires you to individually re-grant the same dozen permissions all support staff need. RBAC breaks that many-to-many relationship into two smaller, much more manageable ones: users are assigned to a small number of roles (rarely more than one or two per user), and roles are assigned permissions (a role rarely has more than 20-30). Changing what 'moderator' can do updates every moderator instantly, because the permission lives on the role, not on each user.

The three core concepts are: a permission is a single, fine-grained capability, ideally named as resource.action (posts.edit, users.delete, billing.view) so it reads clearly in logs and code. A role is a named bundle of permissions (editor, moderator, admin) that represents a job function, not a specific person. And the role-permission mapping is the table (or array, or config) that says which permissions belong to which role -- this is the piece that actually gets checked at authorization time, alongside which role(s) a given user has.

It's worth being precise about vocabulary here because it maps directly onto database tables later: 'Alice has the editor role' is a user-role assignment; 'editor can do posts.edit' is a role-permission assignment. Authorization always resolves through both: user -> role(s) -> permission(s) -> does the required permission appear in that resolved set?

• Permission: one fine-grained capability, e.g. posts.edit, users.ban, billing.refund. • Role: a named bundle of permissions representing a job function, e.g. editor, admin. • Role-permission mapping: which permissions each role grants -- this is what gets checked. • User-role assignment: which role(s) a given user holds -- usually one, sometimes a few. • RBAC turns a huge user-x-permission matrix into two small, maintainable mappings.

A Real Data Model: Roles, Permissions, and Mappings

In a relational database, RBAC is normally four tables: users, roles, permissions, and a join table role_permissions (plus user_roles if a user can hold more than one role). This keeps roles and permissions as first-class, independently manageable rows -- an admin can rename a role, add a new permission, or re-map which permissions a role grants entirely through data, with zero code changes and no deploy.

The example below models this in plain PHP with typed value objects standing in for what would normally be database rows, plus a small RoleRegistry that resolves 'does this user, through their role(s), have this permission?'. Reading it top to bottom mirrors exactly how you'd query the equivalent SQL: join the user to their role(s), join each role to its permissions, and check membership.

Keep permission names granular and consistent (resource.action) rather than broad and vague (canEdit). Granular permissions compose: a support role can be built from tickets.view and tickets.reply without also getting tickets.delete, whereas a single opaque canManageTickets flag can't be split apart later without a migration.

• Tables: users, roles, permissions, role_permissions (join), optionally user_roles (join). • Name permissions resource.action — granular and greppable in logs and code. • Roles should map to job functions ('editor'), not to individual people. • A role-permission mapping lives in data, so granting/revoking a permission for a whole role needs no deploy.

Authorization Middleware: Checking Permissions Before the Controller Runs

Authorization belongs in a layer that runs BEFORE the controller/route handler executes, not scattered as if checks inside business logic. This is exactly what middleware is for: a piece of code that sits between 'the request arrived and the user is authenticated' and 'the controller's actual logic runs', and that can short-circuit the request (typically with a 403 Forbidden) before any of that logic executes. Centralizing the check this way means every protected route enforces authorization the same way, and a missing check becomes obvious (a route with no middleware attached) rather than an easy-to-miss omission buried inside a 200-line controller method.

The pattern is: the middleware is configured with the permission a given route requires (e.g. posts.delete); on each request it reads the already-authenticated user (authentication middleware must run first and populate this), asks the authorization service 'does this user have posts.delete?', and either lets the request continue to the controller or throws/returns a 403 response immediately. The controller itself never has to think about roles or permissions at all -- by the time its code runs, authorization has already happened.

This separation also makes authorization testable in isolation: you can unit-test 'does a user with role X have permission Y' without spinning up HTTP requests or controllers at all, and separately test 'does the middleware correctly reject when the check returns false' with a fake authorization service.

• Middleware runs after authentication, before the controller's business logic. • It answers exactly one question: does this user have the required permission for this route? • On failure: short-circuit with 403 Forbidden and never invoke the controller. • Controllers stay free of role/permission checks -- authorization is enforced upstream, consistently.

Common Pitfall: Role Explosion and Hardcoded Role Checks

A frequent RBAC anti-pattern is checking the role's NAME directly in application code -- if ($user->role === 'admin' || $user->role === 'superadmin') -- scattered across dozens of controllers. Every time product wants a new role with a slightly different set of permissions ('junior moderator' who can hide comments but not ban users), you have to hunt down and edit every one of those scattered string comparisons. This is 'role explosion': the number of roles grows because the code can only reason about roles, not about the underlying capabilities.

The fix is to always check permissions, never role names, in application code -- if ($authorizer->can($user, 'comments.hide')). Roles remain purely a data-layer convenience for grouping permissions together for easy assignment; the code that decides access never needs to know a role's name, only whether the user's resolved permission set contains what's required. This is also what makes the middleware pattern from the previous section possible: it's configured with a permission string, never a role string.

Roles, Permissions, and a Middleware Skeleton

Permissions are plain value objects — the string key is what code actually checks. Role::grants() is the role-permission mapping lookup, resolved in memory here (a real app backs this with a role_permissions table). Middleware implements one method: given the authenticated user and the permission a route requires, allow or deny.

<?php
declare(strict_types=1);

final class Permission
{
    public function __construct(public readonly string $key) {} // e.g. "posts.delete"
}

final class Role
{
    /** @param Permission[] $permissions */
    public function __construct(
        public readonly string $name,
        public readonly array $permissions,
    ) {}

    public function grants(string $permissionKey): bool
    {
        foreach ($this->permissions as $permission) {
            if ($permission->key === $permissionKey) {
                return true;
            }
        }
        return false;
    }
}

interface RequirePermission
{
    public function handle(User $user, string $permission): bool;
}

Code Examples

RoleRegistry::MAP is the role-permission mapping: a single source of truth for what each role can do. User holds exactly one Role. Authorizer::can() is the one method application code ever calls -- it never compares role names, only resolved permissions. Expected result: bool(true) bool(false) return $user->role->grants($permissionKey);

The middleware is parameterized with the permission a route needs (not a role). It rejects unauthenticated requests with 401, rejects authenticated-but-unauthorized requests with 403 before ever calling $next(), and hands control to the controller only once the check has passed -- so the controller body is guaranteed to run only for authorized users. Expected result: Forbidden: missing permission 'posts.delete' if (!$this->authorizer->can($currentUser, $this->requiredPermission)) {... throw... }

Common mistake: Putting the permission check inside the controller method itself instead of in middleware means it's easy to add a new controller action and forget the check entirely -- middleware makes the absence of a check visible at the routing layer.

<?php
declare(strict_types=1);

final class Permission
{
    public function __construct(public readonly string $key) {}
}

final class Role
{
    /** @param Permission[] $permissions */
    public function __construct(
        public readonly string $name,
        public readonly array $permissions,
    ) {}

    public function grants(string $permissionKey): bool
    {
        foreach ($this->permissions as $permission) {
            if ($permission->key === $permissionKey) {
                return true;
            }
        }
        return false;
    }
}

final class User
{
    public function __construct(
        public readonly int $id,
        public readonly string $email,
        public readonly Role $role,
    ) {}
}

// The role-permission mapping: normally rows in `roles` + `role_permissions`
// tables, loaded once and cached. Modeled here as an in-memory registry.
final class RoleRegistry
{
    private const MAP = [
        'viewer'    => ['posts.view'],
        'editor'    => ['posts.view', 'posts.create', 'posts.edit'],
        'moderator' => ['posts.view', 'posts.edit', 'comments.hide', 'users.ban'],
        'admin'     => ['posts.view', 'posts.create', 'posts.edit
…
<?php
declare(strict_types=1);

final class AuthorizationException extends RuntimeException
{
    public function __construct(public readonly string $requiredPermission)
    {
        parent::__construct("Forbidden: missing permission '{$requiredPermission}'");
    }
}

/**
 * Route middleware: configured once per protected route with the
 * permission that route requires. Runs after authentication middleware
 * has already populated the current user; runs before the controller.
 */
final class RequirePermissionMiddleware
{
    public function __construct(
        private readonly Authorizer $authorizer,
        private readonly string $requiredPermission,
    ) {}

    /**
     * @param callable $next The next handler in the pipeline (the controller,
     *                       or the next middleware).
     */
    public function handle(?User $currentUser, callable $next): mixed
    {
        if ($currentUser === null) {
            // Authentication middleware should have run first and rejected
            // this already -- treat a missing user as unauthenticated, not
            // as "no permission", so the caller gets a 401 not a 403.
            http_response_code(401);
   
…

Request flow through authentication and authorization middleware

Authentication establishes identity; authorization middleware gates access before the controller ever runs.

HTTP Request
       |
       v
  +-----------------------+
  | Authentication         |   verifies identity (session/token)
  | middleware             |   populates $currentUser
  +-----------------------+
       |  $currentUser set (or null)
       v
  +-----------------------+
  | RequirePermission       |   Authorizer::can($currentUser, 'posts.delete')
  | middleware               |----> false: 403 Forbidden, STOP
  +-----------------------+----> true:  call $next()
       |
       v
  +-----------------------+
  | Controller              |   business logic only -- no role/permission
  | (posts.delete action)   |   checks live here
  +-----------------------+

Important Rules

Application code checks permissions ('posts.delete'), never role names ('admin') -- roles are a data-layer grouping convenience.

Authorization runs in middleware, after authentication, before the controller -- never scattered inline inside business logic.

Name permissions resource.action so they stay granular, greppable, and composable across roles.

A missing-user case is a 401 (unauthenticated); a present-but-unauthorized user is a 403 (forbidden) -- don't conflate the two.

The role-permission mapping should live in data (a table or config), not hardcoded conditionals, so it can change without a deploy.

Where RBAC Shows Up in Production

Almost every SaaS admin panel uses this exact shape: Stripe's dashboard roles (Admin, Analyst, Developer), GitHub's repository roles (Read, Triage, Write, Maintain, Admin), and WordPress's user roles (Subscriber, Contributor, Author, Editor, Administrator) are all RBAC -- a small, curated set of roles, each mapping to a bundle of fine-grained capabilities, checked via middleware before any state-changing action executes. Frameworks like Laravel formalize this as Gates and Policies; Symfony calls it Voters -- both are the same RBAC (and, as the next lesson shows, ABAC) shape wrapped in framework conventions.

if ($user->role === 'admin' || $user->role === 'moderator') { deletePost($id); } if ($authorizer->can($user, 'posts.delete')) { deletePost($id); }

Hardcoding role names couples every protected action to the exact set of roles that happen to have that capability today. Adding a new role ('junior-moderator') with a slightly different permission set means hunting down and editing every such comparison. Checking the permission directly means new roles just need the right entries in the role-permission mapping -- zero code changes.

In RBAC, what should application/controller code check? A. The user's role name directly, e.g. $user->role === 'admin' B. Whether the user's resolved permission set contains the specific permission the action requires C. The user's database ID against a hardcoded allowlist D. Whether the user is logged in, and nothing more

B. Whether the user's resolved permission set contains the specific permission the action requires — Checking permissions rather than role names decouples code from the exact set of roles that happen to grant a capability today, avoiding role explosion when a new role is introduced.

Where should the authorization check ('does this user have the required permission?') run? A. Inside each controller method, as the first line of business logic B. In the database, via a trigger C. In middleware that runs after authentication and before the controller D. On the client, before the request is even sent

C. In middleware that runs after authentication and before the controller — Centralizing the check in middleware, after authentication has populated the current user and before the controller runs, guarantees every protected route is checked consistently and makes a missing check visible at the routing layer.

A request arrives with no authenticated user for a permission-protected route. What's the correct response? A. 403 Forbidden, same as an authorized-but-lacking-permission user B. 200 OK, and let the controller decide C. 401 Unauthenticated -- the identity itself is missing, not just the permission D. 500 Internal Server Error

C. 401 Unauthenticated -- the identity itself is missing, not just the permission — 401 means 'who are you?' was never established; 403 means identity is known but that identity lacks permission. Conflating the two leaks information and confuses clients trying to recover (e.g. by prompting a login vs. showing an access-denied message).

Add a new permission posts.publish and a new role publisher that has posts.view and posts.publish but not posts.edit. Then write a RequirePermissionMiddleware for a /posts/{id}/publish route requiring posts.publish, and show that a publisher user passes while an editor user (who lacks posts.publish) is rejected with a 403.

Add 'publisher' => ['posts.view', 'posts.publish'] to RoleRegistry::MAP; the editor role's array has no 'posts.publish' entry, so Authorizer::can() returns false for an editor and RequirePermissionMiddleware throws AuthorizationException / sets 403, while it returns true (and calls $next()) for a publisher.

Add the new key to RoleRegistry::MAP under a new 'publisher' entry, construct RequirePermissionMiddleware($authorizer, 'posts.publish'), and call handle() with a User built from each role.