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.
- 🎭 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.
composer require kirchdev/laravel-pbacPublish and run the migrations:
php artisan vendor:publish --tag=pbac-migrations
php artisan migrateOptionally publish the config:
php artisan vendor:publish --tag=pbac-configAdd 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)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.
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.
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
});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.
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. |
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.
composer install
composer test # Pest 4
composer pint # Laravel Pint (test mode)
composer larastan # Larastan / PHPStanThe test suite runs via Testbench + in-memory SQLite — no host app required.
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.
Semantic Versioning. Release notes in CHANGELOG.md — managed by release-please.