bspdx / keystone
Multi-tenant role-based access control (RBAC) for Laravel — bring your own authentication
Requires
- php: ^8.2.0
- laravel/framework: ^12.41|^13.0
Requires (Dev)
- fakerphp/faker: ^1.23
- larastan/larastan: ^3.12
- laravel/boost: ^2.10
- laravel/pail: ^1.2.2
- laravel/pint: ^1.24
- laravel/sail: ^1.41
- mockery/mockery: ^1.6
- nunomaduro/collision: ^8.6
- phpunit/phpunit: ^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
- bspdx/authkit: v1.0.0-rc.1
This package is auto-updated.
Last update: 2026-10-25 16:48:23 UTC
README
A multi-tenant role-based access control (RBAC) package for Laravel. Keystone adds roles, permissions, and tenant-scoped authorization to whatever User model your application already has — it does not provide authentication. Bring your own login, registration, 2FA, and passkeys (Fortify, Breeze, or anything else); Keystone plugs in via a single trait.
- 👥 Role-Based Access Control (RBAC) - Clean service layer API
- 🏢 Multi-Tenancy Ready - Optional, global-scope tenant isolation; one schema in both modes, so you can switch it on or off without a migration
- 🚪 Laravel Gate Integration - Permissions registered via
Gate::before() - 🌐 API-First Design - JSON role/permission management API included, with no-escalation rules built in
- 🔌 Framework-Agnostic Auth - Works alongside any authentication stack
Table of Contents
- Requirements
- Installation
- Configuration
- Usage
- Architecture
- Multi-Tenancy
- Known Issues
- Testing
- Credits
- License
Requirements
- PHP 8.2+
- Laravel 12.41+ or 13.x
- MySQL 5.7+ / PostgreSQL 9.6+ / SQLite 3.8.8+
Installation
Step 1: Install via Composer
composer require bspdx/keystone
Step 2: Publish Configuration & Migrations
php artisan vendor:publish --tag=keystone-config --tag=keystone-migrations # Optional: example RBAC API routes php artisan vendor:publish --tag=keystone-routes # Optional: baseline roles/permissions seeder, plus a demo-user seeder php artisan vendor:publish --tag=keystone-seeders
Step 3: Run Migrations
php artisan migrate
This creates tables for roles and permissions (custom RBAC) and adds a nullable tenant_id
column to your users table. The schema is the same whether KEYSTONE_MULTI_TENANT is on or off:
the roles, permissions, and assignment tables always carry tenant_id, and in single-tenant mode
it stays NULL. You can turn multi-tenancy on or off later without a migration (see
Multi-Tenancy).
Step 4: Seed Demo Data (Optional)
php artisan db:seed --class=KeystoneSeeder
This creates 4 default roles (super-admin, admin, editor, user) with common
permissions. For local development you can also create one demo user per role (password
password). That seeder refuses to run in production:
php artisan db:seed --class=KeystoneDemoUserSeeder
Step 5: Set Up Your Own Authentication
Keystone does not configure or require any authentication package. Install and configure
laravel/fortify (or Breeze, or your own solution) exactly as you would in a Keystone-free
Laravel app. Keystone only needs your User model to implement KeystoneUser and use the
HasKeystone trait (see below) — it has no opinion on how users log in.
Configuration
The package configuration is located at config/keystone.php.
Feature Flags
'features' => [ // Enable multi-tenant mode (scopes roles, permissions, and assignments by tenant_id) 'multi_tenant' => env('KEYSTONE_MULTI_TENANT', false), ],
The tenant_id columns exist in both modes. When multi_tenant is enabled, Keystone writes and
filters by them, using global scopes for automatic tenant isolation (not Spatie's teams
feature). When it's disabled, Keystone ignores them.
Key Features:
- Automatic Filtering - Authenticated users only see roles/permissions for their tenant
- Global Roles/Permissions - Set
tenant_id = NULLfor cross-tenant access - UUID Support -
tenant_idis always a UUID column - Cross-Tenant Queries - Use
::withoutTenant()for cross-tenant operations
See Multi-Tenancy Documentation for detailed architecture, usage examples, and migration guides.
RBAC Settings
'rbac' => [ // Cache expiration time for roles and permissions (in seconds) 'cache_expiration' => 60 * 60 * 24, // 24 hours // Super admin role that bypasses all permission checks // (in multi-tenant mode it must be a global role; the name is reserved in tenants) 'super_admin_role' => 'super-admin', ],
Guards
Whenever you don't pass a guard, Keystone uses your application's default guard
(auth.defaults.guard) — when creating roles and permissions, checking them, and resolving
names. Pass a guard explicitly ($user->hasPermissionTo('edit-posts', 'api')) to use another.
Usage
Adding RBAC to Your User Model
Add the KeystoneUser interface and the HasKeystone trait to your existing User model —
this is Keystone's only integration point. The trait implements every method the interface
declares and adds role/permission relationships and tenant-aware scoping; it does not touch
authentication.
<?php namespace App\Models; use BSPDX\Keystone\Contracts\KeystoneUser; use BSPDX\Keystone\Traits\HasKeystone; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; class User extends Authenticatable implements KeystoneUser { use Notifiable, HasKeystone; // Add whatever your own auth stack requires, e.g.: // use Laravel\Fortify\TwoFactorAuthenticatable; // use Laravel\Sanctum\HasApiTokens; // ... rest of your model }
Keystone resolves your user model from keystone.user.model, then auth.providers.users.model,
and throws a clear LogicException if neither is set or the model doesn't implement
KeystoneUser. Any other model (for example an Admin model on another guard) can hold roles
the same way by declaring implements KeystoneUser and use HasKeystone;.
You can also query users by assigned role directly from the model:
use App\Models\User; $admins = User::role('admin')->get(); $staff = User::role(['admin', 'manager'])->get();
Service Layer
All role and permission operations go through dedicated services, abstracted behind interfaces.
Using Services in Controllers
<?php namespace App\Http\Controllers; use BSPDX\Keystone\Services\Contracts\RoleServiceInterface; use BSPDX\Keystone\Services\Contracts\PermissionServiceInterface; use BSPDX\Keystone\Services\Contracts\AuthorizationServiceInterface; class AdminController extends Controller { public function __construct( private RoleServiceInterface $roleService, private PermissionServiceInterface $permissionService, private AuthorizationServiceInterface $authService ) {} public function assignRole(User $user) { // Get all roles $roles = $this->roleService->getAllWithPermissions(); // Add roles to user (keeps existing roles) $this->authService->assignRolesToUser($user, ['admin', 'editor']); // Or replace the user's roles with exactly this set $this->authService->syncRolesForUser($user, ['editor']); // Check if user has role if ($this->authService->userHasRole($user, 'admin')) { // User is admin } } }
Benefits:
- Clean dependency injection
- Easy to mock for testing
- No direct external package dependencies in your code
Routes
Keystone doesn't auto-register routes. Add them manually from the published examples:
Alternatively, set keystone.load_routes to true to have Keystone load the management API
routes itself (unless you've published routes/keystone-api.php). The web routes are examples
only and are never loaded automatically.
Web Routes (routes/keystone-web.php):
// Include in your routes/web.php require __DIR__.'/keystone-web.php';
API Routes (routes/keystone-api.php):
// Include in your routes/api.php require __DIR__.'/keystone-api.php';
Middleware
Keystone registers these middleware aliases:
| Alias | Purpose |
|---|---|
keystone.role:<role> |
Require one of the listed roles (OR logic) |
keystone.permission:<perm> |
Require one of the listed permissions (OR logic) |
role:<role> |
Same as keystone.role — registered only if your app hasn't defined a role alias |
permission:<perm> |
Same as keystone.permission — registered only if your app hasn't defined a permission alias |
Super-admins pass both. Unauthenticated requests raise Laravel's AuthenticationException, so
your app's own guest handling applies (401 for JSON, redirect for browsers). An authenticated
user whose model doesn't implement KeystoneUser (for example another guard's model) gets 403.
Role Middleware
Route::middleware(['auth', 'role:admin'])->group(function () { // Only users with 'admin' role can access }); // Multiple roles (OR logic) Route::middleware(['auth', 'role:admin,editor'])->group(function () { // Users with 'admin' OR 'editor' role can access });
Permission Middleware
Route::middleware(['auth', 'permission:edit-posts'])->group(function () { // Only users with 'edit-posts' permission }); // Multiple permissions Route::middleware(['auth', 'permission:edit-posts,publish-posts'])->group(function () { // Users with either permission can access });
Checking Permissions in Code
Traditional Approach (User Model Methods)
// Check role if (auth()->user()->hasRole('admin')) { // User is an admin } // Check permission if (auth()->user()->can('edit-posts')) { // User can edit posts } // Check multiple roles if (auth()->user()->hasAnyRole(['admin', 'editor'])) { // User has at least one of these roles } // Super admin check if (auth()->user()->isSuperAdmin()) { // User is super admin (bypasses all permission checks) }
Super-admins and literal checks: super-admins pass every permission check (
hasPermissionTo(),can(), thepermission:middleware) and therole:middleware, buthasRole()/hasAnyRole()/hasAllRoles()andhasDirectPermission()are literal. A super-admin only "has" the roles and direct permissions actually assigned to them. UseisSuperAdmin()or a permission check when you mean "may do anything".
Gate integration: Keystone adds one
Gate::beforecallback, in every context (HTTP, queue workers, the scheduler, tinker). It returnstruefor super-admins and for permissions the user holds, and otherwise returnsnull— so your policies andGate::defineabilities still decide. Keystone never denies on the Gate's behalf, and users whose model doesn't implementKeystoneUserare left to your policies. The permission list is read from the cache store at most once per request or job (Cache::memo()).
Service Layer Approach (Recommended for Controllers)
use BSPDX\Keystone\Services\Contracts\AuthorizationServiceInterface; class PostController extends Controller { public function __construct( private AuthorizationServiceInterface $authService ) {} public function edit(Post $post) { if ($this->authService->userHasPermission(auth()->user(), 'edit-posts')) { // User can edit posts } } }
API Usage
Keystone's role/permission management API is JSON-first, making it easy to drive from any frontend. Protect the API routes with whatever auth guard your application uses:
curl -X GET http://localhost/api/roles \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json"
Add a Role to a User (keeps the user's other roles):
curl -X POST http://localhost/api/users/1/roles \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"roles": ["admin"]}'
Replace a User's Roles (PUT; send [] to remove all):
curl -X PUT http://localhost/api/users/1/roles \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"roles": ["editor"]}'
The same POST adds / PUT replaces pattern applies to /api/users/{user}/permissions and
/api/roles/{role}/permissions.
Response shapes. Every role in a response is {id, name, guard_name, title, description, permissions, created_at}, where permissions is an array of names, and every permission is
{id, name, guard_name, title, description, created_at}. created_at is ISO 8601 (or null).
GET /api/roles adds users_count to each role, and GET /api/permissions adds roles (names).
In multi-tenant mode, a caller with a tenant only sees names and counts from their own tenant and
global rows.
Listing. GET /api/roles and GET /api/permissions are ordered by name. Pass per_page
(1–100) and optionally page to get one page plus a meta object (current_page, per_page,
last_page, total); without per_page the whole list is returned.
Limits. roles / permissions arrays accept at most 100 distinct names (422 otherwise).
The example route group applies throttle:60,1; adjust it to your needs.
Errors. Keystone's exceptions render as JSON client errors: 409 for a duplicate role or
permission, 422 for the reserved super-admin name, 403 for deleting the super-admin role.
They aren't reported to your error log. Any other exception is left to your app's handler.
API Security Rules
- No privilege escalation. A caller who isn't a super-admin can only grant a role if they hold
every permission it grants, and only grant (or add to a role) permissions they hold. Only a
super-admin can grant, remove, or modify the super-admin role. Violations return
403and change nothing. The trait, services, and console commands are not restricted. - Global rows are read-only for tenants. In multi-tenant mode, a caller who belongs to a tenant
gets
403when deleting or changing the permissions of a global role or permission. - Tenant-aware names. Names must be unique within the caller's tenant (and guard), and names
sent to assignment endpoints must exist in the target's tenant (otherwise
422).
Architecture
Service Layer
All role and permission operations go through dedicated services. A null guard always means
your default guard.
Every method that takes a user takes a BSPDX\Keystone\Contracts\KeystoneUser.
- RoleService - Role CRUD and queries
getAllWithPermissions()(ordered by name),paginateWithPermissions($perPage, ?$page),create($name, ?$guard),delete($role),syncPermissions($role, $permissions),getUserRoles(KeystoneUser $user),findByName($name, ?$guard, ?$tenantId)(a tenant row wins over a global one),getAllForTenant(?$tenantId),assignToUser(KeystoneUser $user, $roles),removeFromUser(KeystoneUser $user, $roles),clearCache()
- PermissionService - Permission CRUD and queries
getAllWithRoles()(ordered by name),paginateWithRoles($perPage, ?$page),create($name, ?$guard),delete($permission),syncToUser(KeystoneUser $user, $permissions),getUserPermissions(KeystoneUser $user),getAllUserPermissions(KeystoneUser $user),findByName(),getAllForTenant(),assignToUser(),removeFromUser(),assignToRole(),removeFromRole()
- AuthorizationService - High-level authorization operations
assignRolesToUser(),assignPermissionsToUser()(add),syncRolesForUser(),syncPermissionsForUser()(replace),userHasRole(),userHasAnyRole(),userHasAllRoles(),userHasPermission(),userHasAnyPermission(),userHasAllPermissions(),userHasDirectPermission(),userCanBypassPermissions()— all with the super-admin bypass applied
All services are registered in Laravel's service container with interface bindings and convenient aliases:
keystone.roleskeystone.permissionskeystone.authorizationkeystone.cache
User Contract
BSPDX\Keystone\Contracts\KeystoneUser declares every public method HasKeystone adds
(roles(), permissions(), the assignment methods, the role and permission checks,
getAllPermissions(), isSuperAdmin(), canBypassPermissions()). Keystone decides whether a
model takes part in RBAC with instanceof KeystoneUser.
Models
BSPDX\Keystone\Models\KeystoneRole- Custom role model with tenant scoping andisSuperAdmin()BSPDX\Keystone\Models\KeystonePermission- Custom permission model with tenant scoping
Public methods on the trait and both models are typed: role arguments are
string|int|KeystoneRole|iterable, permission arguments string|int|KeystonePermission|iterable,
mutating methods return static, and checks return bool. Names, integer IDs, models, arrays,
and collections are all accepted; any other type is a TypeError. syncRoles() and
syncPermissions() on a user resolve every name first and replace the assignments in one
transaction, so a typo changes nothing. Deleting a user removes their assignments
(soft-deleted users keep them until force-deleted).
HTTP Layer
The example routes are served by six controllers in BSPDX\Keystone\Http\Controllers, each
using only resource action names:
| Controller | Actions | Routes |
|---|---|---|
RoleController |
index, store, destroy |
GET/POST /api/roles, DELETE /api/roles/{role} |
PermissionController |
index, store, destroy |
GET/POST /api/permissions, DELETE /api/permissions/{permission} |
UserRoleController |
store (add), update (replace) |
POST/PUT /api/users/{user}/roles |
UserPermissionController |
store (add), update (replace) |
POST/PUT /api/users/{user}/permissions |
RolePermissionController |
store (add), update (replace) |
POST/PUT /api/roles/{role}/permissions |
UserAccessController |
show |
GET /api/users/{user}/roles-permissions |
Roles and permissions in responses are built by Http\Resources\RoleResource and
Http\Resources\PermissionResource.
Exceptions
All extend BSPDX\Keystone\Exceptions\KeystoneException, which renders as a JSON response
with a message and isn't reported (ShouldntReport):
CannotDeleteSuperAdminRole- deleting the super-admin role (403)ReservedRoleName- using the super-admin name for a tenant role, multi-tenant (422)RoleAlreadyExists/PermissionAlreadyExists- a duplicate name and guard in the same tenant, or among global rows (409)
Benefits
- Testability - Mock service interfaces in tests instead of facades
- Maintainability - RBAC logic isolated in a dedicated service layer
- Backend-Only - No UI to maintain or theme; drop it into any frontend
Multi-Tenancy
Keystone provides comprehensive multi-tenant support using global scopes for automatic tenant isolation. Roles and permissions can be global (accessible across all tenants) or tenant-specific (isolated per organization).
Keystone handles the RBAC side of multi-tenancy — scoping roles, permissions, and
assignments to a tenant_id. It does not provide a Tenant model, tenant creation, or
user-to-tenant assignment. Your application is responsible for managing tenants and populating
tenant_id on your User model. Keystone reads that value automatically to scope all role and
permission queries.
Quick Start
Enable multi-tenancy in your .env:
KEYSTONE_MULTI_TENANT=true
No migration is needed: the tenant columns exist in both modes. On an existing install, run the one-time data step to move existing assignments into their users' tenants (roles and permissions stay global):
php artisan keystone:enable-multi-tenancy
To go back to single-tenant, run php artisan keystone:disable-multi-tenancy (it refuses when two
tenants share a role or permission name), then set KEYSTONE_MULTI_TENANT=false.
Features
- Automatic Tenant Filtering - Global scopes automatically filter roles/permissions by authenticated user's tenant
- Global Roles/Permissions - Set
tenant_id = NULLto make roles/permissions accessible across all tenants - Tenant-Specific Roles - Roles with
tenant_idare isolated to a single organization - UUID Support -
tenant_idis always a UUID column - Cross-Tenant Queries - Use
::withoutTenant()scope for cross-tenant operations - Global Super-Admin - Only a global role can be the super-admin role; its name is reserved in tenants
Usage Examples
Creating Global Roles
use BSPDX\Keystone\Models\KeystoneRole; // Create a global role accessible to all tenants $superAdmin = KeystoneRole::withoutTenant()->create([ 'name' => 'super_administrator', 'title' => 'Super Administrator', 'tenant_id' => null, // Global role ]);
Creating Tenant-Specific Roles
// tenant_id is auto-populated from authenticated user Auth::login($userInTenantA); $manager = KeystoneRole::create([ 'name' => 'department_manager', 'title' => 'Department Manager', // tenant_id automatically set from auth()->user()->tenant_id ]);
Tenant Query Scopes
Both KeystoneRole and KeystonePermission provide the same scopes. The automatic tenant
scope always applies, so a tenant user already sees their tenant's rows plus global rows. These
scopes narrow that further. They never widen it.
KeystoneRole::global()->get(); // global only (tenant_id = NULL) KeystoneRole::tenantSpecific()->get(); // tenant-owned only (tenant_id NOT NULL) KeystoneRole::forTenant($tenantId)->get(); // that tenant's rows only — no globals KeystoneRole::withoutTenant()->forTenant($otherId)->get(); // explicit cross-tenant read
Super-Admin Operations
// View all roles across all tenants $allRoles = KeystoneRole::withoutTenant()->get(); // Check if user can bypass tenant filtering if ($user->canBypassPermissions()) { // User is super-admin }
Keystone Management Commands
# Create roles and permissions (--tenant creates them in a tenant; global when omitted) php artisan keystone:make-role manager --permissions=view-posts,edit-posts php artisan keystone:make-permission edit-posts php artisan keystone:make-role manager --tenant=019c17d6-1a87-71be-a6a4-718da52579e9 # Assign and remove roles (user by ID or email) php artisan keystone:assign-role jane@example.com admin php artisan keystone:assign-role 42 editor --sync php artisan keystone:unassign-role 42 admin # Assign and remove permissions (--tenant picks which tenant's role to target) php artisan keystone:assign-permission edit-posts --to-role=editor php artisan keystone:assign-permission edit-posts --to-user=42 php artisan keystone:unassign-permission edit-posts --from-role=editor --tenant=019c17d6-1a87-71be-a6a4-718da52579e9 # Switch an existing install's data into or out of tenants php artisan keystone:enable-multi-tenancy php artisan keystone:disable-multi-tenancy
Learn More
For comprehensive documentation on multi-tenancy:
- Multi-Tenancy Architecture - Global scopes vs Spatie teams
- Multi-Tenant Usage Examples - Common patterns and best practices
Known Issues
Role and permission list queries follow the logged-in user. In multi-tenant mode the
automatic tenant scope on KeystoneRole / KeystonePermission reads auth()->user(). In a
request from a tenant user, KeystoneRole::all() returns that tenant's roles plus global ones; in
a queued job, a scheduled command, tinker, or for a user with no tenant, the same query returns
every tenant's rows.
- In code that runs without a logged-in user, name the tenant explicitly:
KeystoneRole::withoutTenant()->forTenant($tenantId)(add->orWhereNull('tenant_id')inside awhere()for global rows), or the services'getAllForTenant($tenantId). - If you don't want the automatic filter at all, call
withoutTenant()on the query, for example once in a query method of your own. - What a user or role holds is not affected:
hasRole(),hasPermissionTo(),can(), and the relationships never depend on who is logged in.
Details, plus a note on duplicates under concurrent writes, are in docs/multi-tenancy.md.
Testing
Run the package tests:
composer test # multi-tenant suite composer test-single-tenant # single-tenant suite
Or with PHPUnit directly:
./vendor/bin/phpunit
Security
See API Security Rules. If you discover any security issues, please email info@bspdx.com instead of using the issue tracker.
Credits
Note: As of v0.10.0, Keystone is a multi-tenant RBAC package only — it does not provide
authentication, and has no runtime dependency on Fortify, Sanctum, or any passkey package.
Bring your own authentication and add the HasKeystone trait to your User model.
License
The MIT License (MIT). Please see License File for more information.
Support
- Documentation: Full documentation
- Issues: GitHub Issues
- Discussions: GitHub Discussions