RBAC & ACL Permissions
Reviewed & published by Brayan K
By the end of this lesson you'll be able to tell authentication from authorization and build a real PHP authorization layer — roles, permissions, per-resource ACLs, policies with ownership checks, and middleware that enforces it all server-side, deny-by-default.
Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Separate authentication (who you are) from authorization (what you can do)
- Design the RBAC database schema: users, roles, permissions, and pivot tables
- Build a can() check with role inheritance and check permissions, not role names
- Add per-resource ACLs for "only this user, on this object" access
- Write a policy/gate with an ownership check so authors edit their own posts
- Enforce permissions server-side with middleware, deny-by-default
1️⃣ Authentication vs Authorization
These two words look alike and get mixed up constantly, but they answer different questions. Authentication is "who are you?" — the login that proves identity. Authorization is "what are you allowed to do?" — the permission check that runs after login, on every protected action. Being logged in does not mean you're allowed to do everything. Authentication always comes first; this lesson is about everything that happens next.
<?php
// Two questions, two different jobs. Don't confuse them.
// AUTHENTICATION = "Who are you?" — proving identity (login).
$loggedInUserId = 7; // the server already verified the password
$isAuthenticated = $loggedInUserId !== null;
// AUTHORIZATION = "What are you allowed to do?" — checking permission.
// You can be authenticated (logged in) and still NOT be authorized.
$canDeleteUsers = false; // this user is a plain member, not an admin
echo $isAuthenticated ? "Authenticated: yes\n" : "Authenticated: no\n";
// Deny-by-default: only allow when permission is explicitly true.
if ($canDeleteUsers) {
echo "Authorized: deleting user...\n";
} else {
echo "Authorized: NO — 403 Forbidden\n"; // blocked even though logged in
}
?>Notice the gate starts from "no" and only flips to "yes" when a permission is explicitly true. That is deny-by-default, and it's the single most important habit in this lesson — anything you forget to allow stays safely blocked.
2️⃣ The RBAC Database Schema
RBAC (Role-Based Access Control) connects three things: users get roles, and roles grant permissions. A user never owns a permission directly — they own roles, and roles carry permissions. Because a user can have many roles and a role can have many permissions, you join them with pivot tables (also called join tables): user_roles and role_permissions. Here's the schema and the single query that answers "can this user do X?".
-- RBAC schema: users get roles, roles grant permissions.
-- Two pivot (join) tables connect the many-to-many relationships.
CREATE TABLE roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL -- 'viewer', 'editor', 'admin'
);
CREATE TABLE permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL -- 'posts.create', 'users.delete'
);
-- Pivot: which permissions a role grants (many-to-many)
CREATE TABLE role_permissions (
role_id INT REFERENCES roles(id),
permission_id INT REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id) -- no duplicate pairs
);
-- Pivot: which roles a user has (many-to-many)
CREATE TABLE user_roles (
user_id INT REFERENCES users(id),
role_id INT REFERENCES roles(id),
PRIMARY KEY (user_id, role_id)
);
-- A permission a user has = any permission granted by any of their roles.
-- One query answers "can this user do X?":
SELECT 1
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = 7 AND p.name = 'posts.create'
LIMIT 1; -- one row back = allowed, no rows = denied3️⃣ Checking Permissions with RBAC
Now the PHP side. The whole point of RBAC is the single method can($userId, $permission) that the rest of your app asks. Role inheritance keeps it tidy: an admin inherits everything an editor can do, which inherits everything a viewer can do — so you define each permission once. Read how getAllPermissions() walks the hierarchy and how can() ends with return false (deny-by-default).
<?php
// Role-Based Access Control (RBAC):
// Users -> Roles -> Permissions, with role inheritance.
// You never grant a permission to a person — you grant it to a ROLE,
// then give people roles. Change the role once, everyone updates.
class RBAC
{
/** @var array<string, array<string>> role => its own permissions */
private array $roles = [];
/** @var array<string, array<string>> role => roles it inherits from */
private array $hierarchy = [];
/** @var array<string, array<string>> userId => roles */
private array $userRoles = [];
public function defineRole(string $name, array $permissions): void
{
$this->roles[$name] = $permissions;
}
public function setHierarchy(string $role, array $inheritsFrom): void
{
$this->hierarchy[$role] = $inheritsFrom; // admin inherits editor, etc.
}
public function assignRole(string $userId, string $role): void
{
$this->userRoles[$userId][] = $role;
}
/** A role's own permissions PLUS everything it inherits (recursively). */
public function getAllPermissions(string $role): array
{
$perms = $this->roles[$role] ?? [];
foreach ($this->hierarchy[$role] ?? [] as $parent) {
$perms = array_merge($perms, $this->getAllPermissions($parent));
}
return array_values(array_unique($perms));
}
/** The one method the rest of your app calls. Deny-by-default. */
public function can(string $userId, string $permission): bool
{
foreach ($this->userRoles[$userId] ?? [] as $role) {
// strict in_array (3rd arg true) avoids loose "0 == 'x'" bugs
if (in_array($permission, $this->getAllPermissions($role), true)) {
return true; // a role granted it -> ALLOW
}
}
return false; // nothing granted it -> DENY
}
public function show(string $userId, string $name, string $permission): void
{
$mark = $this->can($userId, $permission) ? "ALLOWED" : "DENIED";
echo str_pad($name, 18) . str_pad($permission, 16) . "-> {$mark}\n";
}
}
$rbac = new RBAC();
$rbac->defineRole("viewer", ["posts.view", "comments.view"]);
$rbac->defineRole("editor", ["posts.create", "posts.update", "posts.publish"]);
$rbac->defineRole("admin", ["users.create", "users.delete", "settings.update"]);
// Hierarchy: admin inherits editor, which inherits viewer.
$rbac->setHierarchy("admin", ["editor"]);
$rbac->setHierarchy("editor", ["viewer"]);
$rbac->assignRole("u1", "admin"); // Alice
$rbac->assignRole("u2", "editor"); // Bob
$rbac->assignRole("u3", "viewer"); // Charlie
echo "=== Access Checks ===\n";
$rbac->show("u1", "Alice (admin)", "users.delete");
$rbac->show("u1", "Alice (admin)", "posts.view"); // inherited twice over
$rbac->show("u2", "Bob (editor)", "posts.create");
$rbac->show("u2", "Bob (editor)", "users.delete"); // not granted -> deny
$rbac->show("u3", "Charlie (viewer)","posts.view");
$rbac->show("u3", "Charlie (viewer)","posts.create"); // not granted -> deny
$adminPerms = $rbac->getAllPermissions("admin");
echo "\nAdmin has " . count($adminPerms) . " permissions (own + inherited).\n";
?>Charlie the viewer is denied posts.create not because of an explicit rule, but because nothing granted it — the loop finishes and falls through to return false. That is the safe default working for you.
4️⃣ ACL — Per-Resource Permissions
RBAC is broad: "editors can edit posts." But sometimes access is about one specific object — this document, shared with this one person. That's an ACL (Access Control List): a list of "user → resource → allowed actions" entries. Think Google Drive, where you share one file with one colleague as read-only. RBAC and ACL are not rivals; real apps use both.
<?php
// Access Control List (ACL): permissions on a SPECIFIC resource, per user.
// RBAC says "editors can edit posts". ACL says "Bob can edit THIS post".
// Use ACL when access is per-object: shared docs, private folders, etc.
class ACL
{
/** @var array<string, array<string>> "userId:resourceId" => actions */
private array $entries = [];
public function grant(string $userId, string $resource, array $actions): void
{
$this->entries["{$userId}:{$resource}"] = $actions;
}
public function allows(string $userId, string $resource, string $action): bool
{
$actions = $this->entries["{$userId}:{$resource}"] ?? [];
return in_array($action, $actions, true); // deny-by-default
}
}
$acl = new ACL();
// Bob owns doc-7 (read + write); Carol was shared it read-only.
$acl->grant("bob", "doc-7", ["read", "write", "delete"]);
$acl->grant("carol", "doc-7", ["read"]);
// Ask the ACL per (user, resource, action). Anyone unlisted is denied.
printf("Bob write doc-7 : %s\n", $acl->allows("bob", "doc-7", "write") ? "yes" : "no");
printf("Carol write doc-7 : %s\n", $acl->allows("carol", "doc-7", "write") ? "yes" : "no");
printf("Carol read doc-7 : %s\n", $acl->allows("carol", "doc-7", "read") ? "yes" : "no");
printf("Dave read doc-7 : %s\n", $acl->allows("dave", "doc-7", "read") ? "yes" : "no");
?>5️⃣ Policies, Gates & Ownership Checks
A policy is a class that holds all the rules for one resource type (a PostPolicy for posts). A gate is the single yes/no question you ask it: "can this user update this post?" The killer feature is the ownership check: most apps want "editors can edit any post, and authors can edit their own." That's a role-permission check OR an owner comparison — exactly the Laravel policy pattern, shown here from scratch.
<?php
// A POLICY centralises the rules for one resource type (here: Post).
// A GATE is a single yes/no question you ask the policy.
// This is the Laravel pattern, written from scratch so you see the wiring.
class Post
{
public function __construct(public int $id, public int $authorId) {}
}
class PostPolicy
{
public function __construct(private RBAC $rbac) {}
/** Can $userId update THIS post? Role permission OR ownership. */
public function update(string $userId, Post $post): bool
{
// 1) Admins/editors with the role permission can edit any post...
if ($this->rbac->can($userId, "posts.update")) {
return true;
}
// 2) ...otherwise you may only edit a post you own (OWNERSHIP CHECK).
return (int) $userId === $post->authorId;
}
/** Only admins may delete — and never your own account loophole here. */
public function delete(string $userId): bool
{
return $this->rbac->can($userId, "posts.delete");
}
}
// Minimal RBAC so this snippet runs on its own.
class RBAC
{
private array $userPerms = [];
public function assign(string $u, array $perms): void { $this->userPerms[$u] = $perms; }
public function can(string $u, string $perm): bool
{
return in_array($perm, $this->userPerms[$u] ?? [], true);
}
}
$rbac = new RBAC();
$rbac->assign("10", ["posts.update", "posts.delete"]); // user 10 = editor
$rbac->assign("20", []); // user 20 = plain member
$policy = new PostPolicy($rbac);
$post = new Post(id: 99, authorId: 20); // owned by user 20
// The gate: ask the policy, then act. Deny-by-default if it returns false.
printf("Editor (10) update post 99 : %s\n", $policy->update("10", $post) ? "ALLOW" : "DENY");
printf("Owner (20) update post 99 : %s\n", $policy->update("20", $post) ? "ALLOW" : "DENY");
printf("Other (30) update post 99 : %s\n", $policy->update("30", $post) ? "ALLOW" : "DENY");
printf("Member (20) delete any post: %s\n", $policy->delete("20") ? "ALLOW" : "DENY");
?>Now you try. The can() below is almost finished — fill in each ___ using the 👉 hint so it allows only what a role grants, then run it and check the Output panel.
<?php
// 🎯 YOUR TURN — finish the can() check, then run it.
// can() must return true ONLY when one of the user's roles grants the permission.
class Auth
{
private array $rolePerms = ["editor" => ["posts.create", "posts.update"]];
private array $userRoles = ["bob" => ["editor"]];
public function can(string $userId, string $permission): bool
{
foreach ($this->userRoles[$userId] ?? [] as $role) {
// 1) Is $permission inside this role's permission list?
if (in_array($permission, $this->rolePerms[$role] ?? [], ___)) { // 👉 strict compare: put true
return ___; // 👉 a role granted it — return true
}
}
return ___; // 👉 deny-by-default — return false
}
}
$auth = new Auth();
echo $auth->can("bob", "posts.create") ? "create: ALLOW\n" : "create: DENY\n";
echo $auth->can("bob", "users.delete") ? "delete: ALLOW\n" : "delete: DENY\n";
// ✅ Expected output:
// create: ALLOW
// delete: DENY
?>One more — this time the missing piece is the ownership check itself. Add the comparison so an author can edit a post they wrote.
<?php
// 🎯 YOUR TURN — add the OWNERSHIP check so authors can edit their own post.
// Rule: allow if the user has 'posts.update', OR if they wrote the post.
class Post { public function __construct(public int $authorId) {} }
function canUpdate(bool $hasUpdatePerm, int $userId, Post $post): bool
{
if ($hasUpdatePerm) {
return true; // editors can edit anything
}
// 👉 Otherwise allow ONLY when this user owns the post.
return $userId === ___; // 👉 compare $userId with $post->authorId
}
$post = new Post(authorId: 42);
echo canUpdate(false, 42, $post) ? "author edits own : ALLOW\n" : "author edits own : DENY\n";
echo canUpdate(false, 99, $post) ? "stranger edits : ALLOW\n" : "stranger edits : DENY\n";
echo canUpdate(true, 99, $post) ? "editor edits any : ALLOW\n" : "editor edits any : DENY\n";
// ✅ Expected output:
// author edits own : ALLOW
// stranger edits : DENY
// editor edits any : ALLOW
?>6️⃣ Enforcing It with Middleware
All those checks are worthless unless something actually runs them on every request. Middleware is code that sits in front of your controller: each route declares the permission it needs, and the middleware blocks anyone who lacks it before the controller's logic ever executes. Return 401 when nobody is logged in (not authenticated) and 403 when they're logged in but not allowed (not authorized). This is your one enforcement point — the server, not the browser.
<?php
// Enforce authorization in ONE place: middleware that runs before the
// controller. Routes declare the permission they need; the gate blocks
// anyone who lacks it with 403 — the server is the source of truth.
class RBAC
{
private array $userPerms = [];
public function assign(string $u, array $perms): void { $this->userPerms[$u] = $perms; }
public function can(string $u, string $perm): bool
{
return in_array($perm, $this->userPerms[$u] ?? [], true);
}
}
class AuthorizeMiddleware
{
public function __construct(private RBAC $rbac) {}
/** Returns the HTTP status this request would receive. */
public function handle(array $request, string $permission): int
{
if ($request['userId'] === null) {
return 401; // not logged in -> Unauthorized
}
if (!$this->rbac->can($request['userId'], $permission)) {
return 403; // logged in but not allowed -> Forbidden
}
return 200; // OK — let the controller run
}
}
$rbac = new RBAC();
$rbac->assign("u1", ["users.manage", "posts.manage", "settings.update"]); // admin
$rbac->assign("u2", ["posts.manage"]); // editor
$mw = new AuthorizeMiddleware($rbac);
$routes = [
["path" => "GET /admin/users", "perm" => "users.manage", "userId" => "u1"],
["path" => "DELETE /users/5", "perm" => "users.manage", "userId" => "u2"],
["path" => "POST /posts", "perm" => "posts.manage", "userId" => "u2"],
["path" => "PUT /settings", "perm" => "settings.update", "userId" => null],
];
foreach ($routes as $r) {
$status = $mw->handle($r, $r["perm"]);
echo str_pad($r["path"], 22) . "-> {$status}\n";
}
?>Common Errors (and the fix)
- You only check permissions in the UI — hiding a delete button does nothing; anyone can still fire the request with curl or Postman. The button is UX, not security. Always enforce the same check on the server inside your controller or middleware.
- Allow-by-default — your gate blocks a few known-bad cases and lets everything else through. Any action you forget to list stays wide open. Flip it: start from return false and only return true when a permission is explicitly granted, so forgotten cases fail closed.
- Role explosion — checking if ($user->role === 'admin') in dozens of places means every new role is a code hunt. Check the permission ($user->can('users.delete')) and let roles decide who holds it. Adding a role then touches zero controllers.
- No ownership check — granting posts.update to a role lets a member edit everyone's posts, not just their own. For per-object access add an owner comparison ($userId === $post->authorId) in the policy alongside the role check.
Pro Tips
- 💡 Cache permissions per session. Load a user's full permission set once at login and stash it; don't hit the database on every can() call.
- 💡 Name permissions resource.action. Patterns like posts.create and users.delete stay readable and let you group or wildcard them later.
- 💡 Let frameworks help. Laravel's Gates/Policies and packages like spatie/laravel-permission implement exactly this pattern — now that you've built it by hand, you know what they're doing.
📋 Quick Reference — RBAC vs ACL
| Aspect | RBAC | ACL |
|---|---|---|
| Granted to | Roles (then roles → users) | A specific user, per resource |
| Scope | Broad capability ("edit posts") | One object ("edit doc-7") |
| Scales by | Number of roles | Number of resources × users |
| Best for | Job-based access (admin/editor) | Sharing (Drive, folders) |
| Check call | can($user, 'posts.edit') | allows($user, $res, 'edit') |
| Default | Deny (return false) | Deny (return false) |
Mini-Challenge: A Deny-by-Default Gate
No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is the same write-run-check loop you'll use on every real authorization check.
🎉 Lesson Complete!
- ✅ Authentication proves who you are; authorization decides what you can do — separate steps, login first
- ✅ RBAC wires users → roles → permissions through pivot tables, with role inheritance so you define each permission once
- ✅ Check the permission (can('posts.edit')), never the role name
- ✅ ACLs add per-resource access for "this user, on this object" (sharing)
- ✅ A policy/gate combines a role check with an ownership check so authors edit their own posts
- ✅ Enforce everything in middleware on the server, deny-by-default — the UI is never the security boundary
Practice quiz
What is the difference between authentication and authorization?
- Authentication is what you can do; authorization is who you are
- They are the same thing
- Authentication is who you are (login); authorization is what you are allowed to do
- Authorization always runs before authentication
Answer: Authentication is who you are (login); authorization is what you are allowed to do. Authentication proves identity (login); authorization checks permission afterward — a logged-in user can still be denied an action.
In RBAC, how are permissions connected to people?
- Users get roles, and roles grant permissions
- Permissions are granted directly to each user
- Permissions are stored in the session only
- Each permission is a separate user account
Answer: Users get roles, and roles grant permissions. You grant permissions to a role, then assign roles to users; change the role once and everyone with it updates.
What database structures connect the many-to-many RBAC relationships?
- Views
- Triggers
- A single flat permissions column
- Pivot (join) tables like user_roles and role_permissions
Answer: Pivot (join) tables like user_roles and role_permissions. Because a user has many roles and a role has many permissions, pivot tables user_roles and role_permissions join them.
What does 'deny-by-default' mean for a permission gate?
- Allow everything unless explicitly blocked
- Start from 'no' and only return 'yes' when a permission is explicitly granted
- Deny all logged-in users
- Require two permissions for every action
Answer: Start from 'no' and only return 'yes' when a permission is explicitly granted. Deny-by-default means anything you forget to allow fails closed (a 403), which is safe; every can()/allows() ends with return false.
Why should you check the permission, not the role name, in your code?
- Hard-coding role names means every new role is a code hunt; checking the permission keeps logic stable
- Role names are slower to compare
- Permissions are case-insensitive
- Roles can't be stored in a database
Answer: Hard-coding role names means every new role is a code hunt; checking the permission keeps logic stable. Check capability (can('users.delete')) and let roles decide who has it, so adding or reshuffling roles never touches your controllers.
When is an ACL the right tool over RBAC?
- When permissions group by job title
- When you have only one user
- When access is per specific object — this document shared with this user
- When you never use roles
Answer: When access is per specific object — this document shared with this user. RBAC says 'editors can edit posts'; an ACL says 'Bob can edit THIS post' — use it for per-object sharing like Google Drive.
In a policy, what is an ownership check?
- Verifying the user owns the server
- Allowing a user to act on a resource they created, in addition to a role permission
- Checking the database owner
- Confirming the user is an admin
Answer: Allowing a user to act on a resource they created, in addition to a role permission. The pattern is 'editors can edit any post, AND authors can edit their own' — a role-permission check OR an owner comparison.
What status code should middleware return for a logged-in user who lacks permission?
- 200
- 401
- 404
- 403
Answer: 403. Return 401 when nobody is logged in (not authenticated) and 403 when they're logged in but not allowed (not authorized).
Is hiding a button in the UI enough to protect an action?
- Yes, it fully blocks the action
- No — anyone can still send the HTTP request; authorization must be enforced server-side
- Yes, if the button is also disabled
- Only if JavaScript is enabled
Answer: No — anyone can still send the HTTP request; authorization must be enforced server-side. Hiding a button is UX only; the server check inside the controller or middleware is the actual security boundary.
In the lesson's RBAC engine, how does role inheritance work?
- Each role must redefine every permission
- Inheritance is disabled in PHP
- A role's permissions are its own PLUS everything it inherits, gathered recursively
- Only admins can inherit
Answer: A role's permissions are its own PLUS everything it inherits, gathered recursively. getAllPermissions() merges a role's own permissions with those of the roles it inherits from, so admin inherits editor inherits viewer.
Continue this course
- Previous: API Rate Monitoring, Logging & Audit Trails
- Next: File Storage Abstraction: Local, S3, Cloud Storage — Abstract file storage with Flysystem to switch between local, S3, and cloud
- Quick reference: PHP cheat sheet
- From the blog: Working With Sessions and Cookies in PHP
Frequently asked questions
What is the difference between authentication and authorization?
Authentication answers "Who are you?" — it is the login step that proves identity with a password, token, or OAuth. Authorization answers "What are you allowed to do?" — it is the permission check that runs after login. They are separate: a fully logged-in (authenticated) user can still be denied (not authorized) from deleting another account. You always do authentication first, then authorization on every protected action.
When should I use RBAC versus ACL?
Use RBAC (role-based) when permissions group naturally by job: viewers, editors, admins. You grant permissions to a role once and assign roles to people, so it scales to thousands of users without per-user fiddling. Use ACL (access control list) when access is per-object — this specific document, folder, or record shared with this specific user, like Google Drive sharing. Most real apps combine them: RBAC for broad capabilities ("editors can edit posts") plus an ownership/ACL check for the specific resource ("…but only their own post").
Why should I check permissions, not roles, in my code?
If you write if (user.role === 'admin') everywhere, you have hard-coded a role name into business logic. The day you add a "super-editor" role or split admin into two, you must hunt down and change every check. Instead check the capability — if (user.can('users.delete')) — and let roles decide who has that permission. Adding or reshuffling roles then never touches your controller code.
Is hiding a button in the UI enough to protect an action?
No. Hiding or disabling a button is UX polish only — anyone can still send the underlying HTTP request directly with curl, the browser dev tools, or Postman. Authorization must be enforced on the server, inside the controller or middleware, on every request. The frontend check is a convenience; the server check is the actual security boundary.
What does 'deny-by-default' mean and why does it matter?
Deny-by-default means your gate starts from "no" and only returns "yes" when a permission is explicitly granted. The opposite — allow-by-default, where you only block a few known-bad cases — is dangerous because any action you forget to block stays open. With deny-by-default a forgotten permission simply fails closed (a 403), which is safe. Every can()/allows() method in this lesson returns false at the end for exactly this reason.