Looking to hire Laravel developers? Try LaraJobs

laravel-pbac maintained by kirchdev

Description
Policy-based access control for Laravel: roles, permissions, organisation-scoped authorization, Gate integration, and a decision cache.
Author
Last update
2026/05/30 02:06 (dev-release-please--branches--main--components--laravel-pbac)
License
Downloads
6

Comments
comments powered by Disqus

🛡️ 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.

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,
    'redact_in_production' => true,
],
$response = Gate::inspect('posts.update', $post);

$response->code();    // 'pbac.granted'
$response->message(); // 'role:editor → permission:posts.update (org-scoped)'

Production environments redact role names and target details by default — opt-in to surface them per-route if you need.

⚙️ 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.

🧪 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