kirchdev / laravel-pbac
Policy-based access control for Laravel: roles, permissions, organisation-scoped authorization, Gate integration, and a decision cache.
Requires
- php: ^8.4
- illuminate/auth: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/events: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pint: ^1.29
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.7
- pestphp/pest-plugin-laravel: ^4.1
This package is auto-updated.
Last update: 2026-05-21 23:20:20 UTC
README
π‘οΈ laravel-pbac
Policy-based access control for Laravel β roles, permissions, multi-tenant scoping, decision tracing, and native Gate integration.
Pbac::withOrganisation($org->id, fn () => $user->can('members.invite')); // β
That's it. Tenant-aware authorization in one line, native Laravel Gate semantics, no manual scope plumbing.
β¨ Features
- π Roles & permissions β plain Eloquent models you can swap out for your own (UUID / ULID / int keys).
- π’ Organisation/tenant scoping β first-class, with a pluggable
OrganisationResolver. Scopes never bleed across tenants. - πͺ Native
Gateintegration β$user->can(),Gate::allows(),Gate::inspect()all Just Work, with fallback to native Laravel gates. - β‘ Per-request decision cache β repeated checks within a request are free. Auto-invalidates on role/permission mutations.
- π Decision trace β opt-in audit trail of why a check returned what it did. Redacted in production by default.
- π Octane-aware β optional reset listeners on
RequestTerminated,TaskTerminated,TickTerminated. No stale state across requests. - π§° Heavy configuration β model / table / column / key types all overridable. UUID setups supported out of the box.
- π§ͺ Library-grade β Pest 4 + Testbench, no host app needed.
π¦ Installation
composer require kirchdev/laravel-pbac
Publish and run the migrations:
php artisan vendor:publish --tag=pbac-migrations php artisan migrate
Optionally publish the config:
php artisan vendor:publish --tag=pbac-config
π Quick start
Add the HasRoles trait to whichever model should be authorizable:
use Illuminate\Foundation\Auth\User as Authenticatable; use KirchDev\Pbac\Traits\HasRoles; class User extends Authenticatable { use HasRoles; }
Create roles, attach permissions, assign, check:
use KirchDev\Pbac\Models\{Permission, Role}; $role = Role::create(['name' => 'editor']); $role->permissions()->attach( Permission::create(['name' => 'posts.update']) ); $user->assignRole($role); $user->can('posts.update'); // β true Gate::allows('posts.update'); // β true (same plumbing) Gate::inspect('posts.update'); // β Response with trace (if enabled)
π’ Multi-tenant authorization
Enable organisation scoping:
// config/pbac.php 'organisation' => [ 'enabled' => true, 'resolver' => \KirchDev\Pbac\Organisation\DefaultOrganisationResolver::class, ],
Scope authorization for the current request:
use KirchDev\Pbac\Facades\Pbac; Pbac::withOrganisation($organisation->id, function () use ($user) { $user->can('members.invite'); // checked against org-bound roles $user->can('billing.view'); // β¦same scope }); // Global checks β no active org Pbac::withoutOrganisation(fn () => $user->can('admin.impersonate'));
The decision cache resets on scope enter/exit, so checks never bleed across tenants.
Assigning global roles when org scoping is enabled
To prevent silent mis-targeting, role mutations by name refuse to resolve a global role unless the caller signals intent. Pass global: true, or wrap the call in Pbac::withoutOrganisation() and assign by Role instance:
$user->assignRole('superadmin', global: true); $user->removeRole('superadmin', global: true); $user->syncRoles(['superadmin', 'support_lead'], global: true); $user->hasRole('superadmin', global: true); // Equivalent for arbitrary mixed batches: $role = Role::findOrCreate('superadmin'); Pbac::withoutOrganisation(fn () => $user->assignRole($role));
Inside an active organisation scope, $user->assignRole('owner') resolves the org-scoped row only β global rows with the same name are deliberately invisible to mutations without the explicit flag.
Bring your own resolver (e.g. backed by a tenancy package or route binding):
final class TenantRouteResolver implements \KirchDev\Pbac\Contracts\OrganisationResolver { public function getOrganisationId(): int|string|null { return request()->route('organisation')?->getKey(); } // β¦setOrganisationId, clearOrganisationId }
Wire it via pbac.organisation.resolver.
π Decision trace
Wondering why a permission check returned what it did? Turn on tracing:
// config/pbac.php 'trace' => [ 'enabled' => true, // null β auto: redact when APP_ENV=production AND APP_DEBUG=false // true|false β forced 'redact' => null, 'log' => [ 'enabled' => false, // structured logging via Laravel's logger 'channel' => null, // null = default channel 'level' => 'info', 'on' => 'deny', // or 'all' ], ],
Gate::inspect() carries the decision's reason code via Response::message():
$response = Gate::inspect('posts.update', $post); $response->allowed(); // bool $response->message(); // 'pbac.role_permission_allowed' | 'pbac.no_matching_role_permission' | β¦
For the human-readable trace, reach for the last decision through the Pbac facade:
use KirchDev\Pbac\Facades\Pbac; $user->can('posts.update', $post); Pbac::lastDecision()?->trace()->visible(); // structured entries Pbac::lastDecision()?->trace()->formatted(); // 'role_permission_query(allowed=1, targeted=1) β role_permission_allowed'
Production redacts trace context arrays by default (step names stay; values are stripped). Opt in to the full trace per request when you need it β e.g. for an admin debug route:
Pbac::withUnredactedTrace(function () use ($user, $post) { $user->can('posts.update', $post); return Pbac::lastDecision()?->trace()->formatted(); // unredacted });
π§Ή Cascade behaviour on delete
Foreign keys are deliberately set to ON DELETE CASCADE so the indexes never carry stale grants or assignments. Mark this on your operational checklist:
| When you delete⦠| These rows go away automatically |
|---|---|
A Role |
All role_has_permissions rows for that role + all model_has_roles rows. |
A Permission |
All role_has_permissions rows referencing it. |
A host model (e.g. User) row |
Not automatic. model_has_roles rows on the morph side are orphaned. |
The host-model side is polymorphic (model_type + model_id), so no FK enforces it. Hook your model's deleting/deleted events or run a periodic prune job if user/team deletions are part of your normal flow. If you need an audit trail of historical grants/assignments, capture it before deletion β once the cascade fires, the rows are gone.
βοΈ Configuration highlights
config/pbac.php is heavily parameterised β see the file for inline docs. Most common knobs:
| Key | What it controls |
|---|---|
models.* |
Swap any of the 4 Eloquent models (Role / Permission / RoleAssignment / RolePermission). |
table_names.* |
Override defaults if they collide with existing tables. |
keys.* |
id / uuid / ulid for primary keys, model morphs, target morphs, org FK. Set before migrations. |
column_names.* |
Pivot and morph key column names (handy for UUID setups). |
organisation.enabled / .resolver |
Toggle multi-tenancy, plug a custom resolver. |
gate.fallback_to_laravel_gates |
Whether unmatched abilities fall back to native Laravel gates. |
trace.enabled |
Capture per-decision explanations. Redacted in prod by default. |
cache.decision_store |
Decision cache backend (request by default). |
register_octane_reset_listener |
Reset scoped state at Octane worker boundaries. |
π Migrating from spatie/laravel-permission
Coming from spatie/laravel-permission? See the dedicated guide for schema, API,
and multi-tenancy differences plus a copy-pasteable data-migration script:
docs/migration-from-spatie-laravel-permission.md.
π§ͺ Testing
composer install composer test # Pest 4 composer pint # Laravel Pint (test mode) composer larastan # Larastan / PHPStan
The test suite runs via Testbench + in-memory SQLite β no host app required.
π€ Contributing
PRs welcome. Conventional Commits required (enforced via commitlint). Husky runs Pint + Larastan + oxlint + oxfmt on git commit, so you can mostly forget about style.
Tip
Run pnpm check:fix (Node tooling) and composer pint:fix (PHP) before pushing β CI will catch what husky missed.
π£οΈ Versioning
Semantic Versioning. Release notes in CHANGELOG.md β managed by release-please.