kirchdev/laravel-pbac

Policy-based access control for Laravel: roles, permissions, organisation-scoped authorization, Gate integration, and a decision cache.

Maintainers

Package info

github.com/kirchDev/laravel-pbac

pkg:composer/kirchdev/laravel-pbac

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-05-21 22:55 UTC

README

πŸ›‘οΈ laravel-pbac

Policy-based access control for Laravel β€” roles, permissions, multi-tenant scoping, decision tracing, and native Gate integration.

Latest Version on Packagist Total Downloads Tests PHP Version Laravel Version License: MIT

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 Gate integration β€” $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.

πŸ“„ License

MIT Β© Titus Kirch / IT-Dienstleistungen Titus Kirch