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
- spatie/laravel-package-tools: ^1.93.1
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/octane: ^2.17
- laravel/pint: ^1.29
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-05 17:28:11 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
Optionally publish the config:
php artisan vendor:publish --tag=pbac-config
Publish and run the migrations. This one-time publish is required β the package ships the migrations but never loads them, so nothing reaches your schema until you have a copy of your own:
php artisan vendor:publish --tag=pbac-migrations php artisan migrate
Important
Set pbac.keys.* in the published config before you run migrate β the migrations read that
config at run time and bake the key types into the schema.
π 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.
π§° Console commands
Bootstrapping the first administrator is a data operation, not a migration. Two always-registered commands cover it:
# Grant a global role php artisan pbac:role:assign 1 superadmin --global # Grant an organisation-bound role, looking the target up by email php artisan pbac:role:assign ops@example.com owner --organisation=42 --column=email # Take it away again php artisan pbac:role:revoke 1 superadmin --global
| Option | What it does |
|---|---|
--global |
Target the global role. Required when organisation scoping is on and the role is not organisation-bound. |
--organisation |
Target the role bound to this organisation. Mutually exclusive with --global. |
--model |
Fully qualified target model class. Overrides pbac.models.default_model and the default guard's auth provider. |
--column |
Column to look the identifier up on. Defaults to the primary key. |
Important
Scope is never inferred. While pbac.organisation.enabled is on, exactly one of --global / --organisation= is required β a forgotten flag must not silently grant a global role in production. With the feature off, neither flag applies.
Both commands write through the HasRoles trait, so they inherit its strict scope resolution and decision-cache reset; a target model that does not use the trait is rejected rather than written at pivot level. After the change they print the target's resulting role set, which is the verification a bootstrap call needs. An ambiguous --column lookup is refused rather than resolved to the first match.
π 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.
β¬οΈ Upgrading
Migrations are publish-only (breaking)
The service provider no longer calls loadMigrationsFrom(). The migrations reach your application
through the pbac-migrations tag only, so schema changes are reviewed in your repository instead of
arriving with a composer update.
Publish them once before your next deploy:
php artisan vendor:publish --tag=pbac-migrations
Published copies are stamped with the moment you publish them, so they slot into your own migration timeline rather than carrying the package's dates into your repository.
If you already published them from an earlier release, this is a no-op: publishing reuses the
filenames you already have, so nothing is duplicated and php artisan migrate finds nothing new.
If you relied on the auto-load and never published, this is a breaking change and needs a hand.
Your database already has the pbac tables, but recorded them under the package's own filenames β
the published copies carry generated ones, which Laravel reads as unrun. The next migrate would
try to create tables that already exist. Reconcile your migrations table with the published
filenames before that run.
π€ 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.