skarner2016 / rbac
Framework-agnostic RBAC for PHP: role-only permissions with menu, route and button types.
This package is auto-updated.
Last update: 2026-11-11 00:11:15 UTC
README
A small, framework-agnostic RBAC library for PHP built directly on PDO.
Permissions are granted only through roles. There is no user-to-permission table and no direct grant API. Permissions live in one tree and are classified as menu, route, or button.
- Zero third-party runtime dependencies: only
ext-pdoandext-json. - MySQL/MariaDB and SQLite support.
- Menu tree with automatic ancestor completion.
- Case-insensitive ASCII permission and role codes.
- Route matching with
*,{id}, and:id. - Optional super role and disabled role/permission handling.
- No framework, no service container, no Eloquent.
Requirements
- PHP 8.2 or newer
ext-pdo- MySQL 5.7+/8.x, MariaDB 10.2+, or SQLite 3
- MySQL
utf8mb4is recommended
Installation
composer require skarner2016/rbac
The package has no runtime dependencies beyond PHP extensions. It does not publish a service provider or require Laravel.
Quick start
1. Create the schema
use PDO; use Skarner\Rbac\Support\Schema; $config = require __DIR__ . '/config/rbac.php'; $pdo = new PDO('mysql:host=127.0.0.1;dbname=app;charset=utf8mb4', $user, $password); Schema::up($pdo, $config);
Schema::up() is idempotent. It creates four tables using the names in the
configuration.
2. Create the RBAC instance
use Skarner\Rbac\Rbac; $rbac = new Rbac($pdo, $config);
3. Create a permission tree
use Skarner\Rbac\Enums\PermissionType; $permissions = $rbac->permissions(); $system = $permissions->create(PermissionType::Menu, 'System', 'System', [ 'path' => '/system', 'icon' => 'settings', 'sort' => 10, ]); $users = $permissions->create(PermissionType::Menu, 'System:User', 'Users', [ 'parent_id' => $system->id, 'path' => 'user', 'component' => 'system/user/index', 'sort' => 10, ]); $permissions->create(PermissionType::Route, 'System:User:List', 'List users', [ 'parent_id' => $users->id, 'method' => 'GET', 'path' => '/api/users', ]); $permissions->create(PermissionType::Button, 'System:User:Create', 'Create user', [ 'parent_id' => $users->id, ]);
4. Grant permissions to a role and assign the role
$role = $rbac->roles()->create('auditor', 'Auditor'); $rbac->roles()->syncPermissions($role->id, [ 'System:User:List', 'System:User:Create', ]); // subject_type defaults to "user" $rbac->roles()->assign($role->id, 7);
5. Check access
$access = $rbac->for(7); $access->can('System:User:Create'); // true $access->can('system:user:create'); // true (case-insensitive) $access->canAny(['A', 'System:User:List']); // true $access->canAll(['System:User:List', 'System:User:Create']); // true $access->canRoute('GET', '/api/users'); // true $access->routeCodes(); // ["System:User:List"] $access->authorize('System:User:Create'); // throws AccessDeniedException if denied
6. Return the menu tree
$menus = $rbac->for(7)->menus();
The tree contains only menu permissions. Each node also lists the granted
route/button codes below it:
[
{
"id": "1",
"parentId": null,
"type": "menu",
"code": "System",
"name": "System",
"path": "/system",
"component": null,
"redirect": null,
"icon": "settings",
"sort": 10,
"hidden": false,
"meta": {},
"permissions": ["System:User:List", "System:User:Create"],
"children": [
{
"id": "2",
"parentId": "1",
"type": "menu",
"code": "System:User",
"name": "Users",
"path": "user",
"component": "system/user/index",
"redirect": null,
"icon": null,
"sort": 10,
"hidden": false,
"meta": {},
"permissions": ["System:User:List", "System:User:Create"],
"children": []
}
]
}
]
Node IDs are strings so JavaScript consumers do not lose large integers.
children is always an array. meta is returned as stored; merge it into your
frontend route meta if needed.
Core concepts
Role-only grants
subjects ──< subject_has_roles >── roles ──< role_has_permissions >── permissions
There is deliberately no subject_has_permissions table and no
$access->givePermissionTo() method. A subject gets permissions only through
its roles.
Permission types
| Type | Purpose | Useful fields |
|---|---|---|
menu |
Frontend navigation node | path, component, redirect, icon, sort, hidden, meta |
route |
Backend endpoint guard | method, path |
button |
Frontend action or backend action | code |
Only a menu can be a parent. A menu can own many route and button
permissions through parent_id, which is what "one menu, multiple permissions"
means in this package.
Menu visibility
A menu is visible when:
- the menu itself is granted, or
- any of its descendants is granted.
Ancestor menus are added automatically, so granting only
System:User:Create still produces the System → System:User path. A
disabled parent hides the whole subtree. Disabled roles and disabled
permissions never grant access.
Case-insensitive codes
Permission and role codes are ASCII case-insensitive for lookup and uniqueness:
- MySQL:
utf8mb4_unicode_cioncode - SQLite:
COLLATE NOCASEoncode - PHP:
strtolower()lookup keys
Stored and returned codes keep their original casing, so a code created as
System:User:Create is still returned that way. SQLite NOCASE folds ASCII
only, so keep codes ASCII.
Configuration
<?php declare(strict_types=1); return [ 'tables' => [ 'roles' => 'rbac_roles', 'permissions' => 'rbac_permissions', 'role_has_permissions' => 'rbac_role_has_permissions', 'subject_has_roles' => 'rbac_subject_has_roles', ], 'subject_type' => 'user', 'menu' => [ 'include_permission_codes' => true, ], ];
- Table names must be valid SQL identifiers and cannot contain a schema prefix.
They can all be changed, for example to
admin_*names. subject_typeis an arbitrary discriminator. Useassign($roleId, $id, 'admin_user')andfor($id, 'admin_user')if your application has more than one subject type.- Set
menu.include_permission_codestofalseto return an emptypermissionsarray for every menu node.
Permission management
$permissions = $rbac->permissions(); $permissions->create(PermissionType::Menu, 'Orders', 'Orders', [ 'path' => '/orders', 'meta' => ['title' => 'menu.orders', 'order' => 20], ]); $permissions->update($permissionId, [ 'name' => 'Order management', 'status' => 1, 'hidden' => false, ]); $permissions->find($id); $permissions->findByCode('orders'); $permissions->all(PermissionType::Menu); $permissions->all(PermissionType::Route, true); // only enabled $permissions->children($parentId); $permissions->delete($leafId);
Rules enforced by PermissionManager:
parent_idmust reference an existingmenu, or benull/0.- A permission cannot be its own parent and cycles are rejected.
- A permission with children cannot be deleted.
- A permission with children cannot change from
menutoroute/button. metamust be an array and is stored as JSON.methodis uppercased and limited to 16 characters.
Role management
$roles = $rbac->roles(); $role = $roles->create('auditor', 'Auditor', [ 'description' => 'Read-only auditor', 'status' => 1, 'is_super' => 0, 'sort' => 10, ]); $roles->grant($role->id, ['System:User:List', $permissionId, $permissionObject]); $roles->revoke($role->id, ['System:User:List']); $roles->syncPermissions($role->id, ['System:User:List']); $roles->permissionCodes($role->id); $roles->permissions($role->id); $roles->assign($role->id, 7); $roles->unassign($role->id, 7); $roles->syncSubjectRoles(7, [$roleA->id, $roleB->id]); $roles->subjectRoles(7); $roles->subjectCount($role->id); $roles->delete($role->id);
Permission references accept:
- a permission code string, e.g.
System:User:List - a permission ID, e.g.
42 - a numeric string, e.g.
"42"(treated as an ID, so avoid purely numeric codes) - a
Permissionobject - an explicit array, e.g.
['id' => 42]or['code' => 'System:User:List']
Deleting a role removes both its permission links and subject assignments in one transaction.
Authorization
$access = $rbac->for(7); $access->codes(); // all granted codes, original casing $access->permissions(); // granted Permission DTOs $access->routeCodes(); // only type=route $access->roleCodes(); // active role codes $access->isSuper(); $access->can('System:User:Create'); $access->canAny(['A', 'B']); $access->canAll(['A', 'B']); $access->hasRole('auditor'); $access->hasAnyRole(['auditor', 'admin']); $access->hasAllRoles(['auditor', 'admin']); $access->authorize('System:User:Create'); $access->authorizeAny('A', 'B');
Empty-set semantics:
canAny([])isfalsecanAll([])istruehasAnyRole([])isfalsehasAllRoles([])istrue
A super role (is_super = 1, enabled) bypasses permission checks and sees all
enabled menus. It does not bypass hasRole() checks.
authorize() and authorizeAny() throw
Skarner\Rbac\Exceptions\AccessDeniedException, which carries
subjectType, subjectId, and codes.
Route permissions
canRoute() checks only granted route permissions:
$access->canRoute('GET', '/api/users'); $access->canRoute('GET', '/api/orders/42');
Matching rules:
methodempty or*matches any method; otherwise comparison is case-insensitive.pathsupports exact paths,*,{id}, and:id.*matches any characters, including/.{id}and:idmatch exactly one path segment.:idis only recognized at the start of a segment, so a literal colon in a segment such as/api/urn:foois matched literally.
- Query strings and fragments are ignored.
- Leading/trailing slashes are normalized.
- Paths are case-sensitive.
Use an explicit can('code') check for critical actions. canRoute() is a
convenience for endpoint guards, not a replacement for explicit permission
codes.
Schema and migrations
use Skarner\Rbac\Support\Schema; Schema::up($pdo, $config); Schema::down($pdo, $config); // drops all four tables Schema::sql('mysql', $config); // array of DDL statements without semicolons Schema::sql('sqlite', $config);
The migration file returns a closure:
$migration = require __DIR__ . '/database/migrations/2026_10_25_000000_create_rbac_tables.php'; $migration($pdo, $config);
Supported drivers: mysql (including MariaDB) and sqlite. Other drivers throw
RbacException. No foreign keys are created; the managers clean pivot rows in
transactions.
Caching
Rbac keeps an in-memory array cache per instance:
- full permission list
- role → permission IDs
- subject grants
All writes through RoleManager / PermissionManager clear the cache
automatically. Access always reads through GrantResolver, so the same
Access instance sees changes after a write.
Direct database writes bypass the cache. Call $rbac->clearCache() if you
modify tables outside this package. There is no persistent/shared cache in this
release.
Long-running processes
The cache is request-scoped and unbounded by default. In long-running CLI or worker processes (Swoole, RoadRunner, etc.), memory usage grows with the number of subjects. To bound memory, pass a maximum item count to the constructor:
$rbac = new Rbac($pdo, $config, cacheMaxItems: 1000);
When the limit is reached, the least recently used entries are evicted.
Exceptions
| Exception | When |
|---|---|
RbacException |
invalid configuration, missing role/permission, duplicate code, invalid parent, permission with children, unsupported driver |
AccessDeniedException |
authorize() / authorizeAny() denied |
AccessDeniedException extends RbacException.
Testing
composer install
composer test
The suite runs on SQLite in-memory and covers the schema, permission tree validation, role grants, case-insensitive lookup, menu ancestor completion, route matching, cache invalidation, custom table names, and the absence of direct subject grants.
OK (146 tests, 324 assertions)
Releasing
See docs/releasing.md for the Packagist release checklist.
Security
- Enforce authorization on the server for every protected route and action. Do not rely on hidden menus or buttons as access control.
- Protect role and permission management endpoints with a separate administrator authorization.
canRoute()is a convenience matcher. Use explicitcan('code')checks for high-risk operations.- Assign roles only through trusted administration flows; there is no direct subject-to-permission grant path by design.
License
MIT. See LICENSE.