Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Permission Manager Laravel Package

hosseinhezami/laravel-permission-manager

Enterprise-grade Laravel permission manager combining RBAC, ABAC, role hierarchy, multi-tenancy, wildcard and temporary permissions, condition engine, multi-guard support, audit logging, middleware DSL, Blade directives, and Gate/Policy integration.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire up in five minutes

composer require hosseinhezami/laravel-permission-manager

php artisan vendor:publish --provider="HosseinHezami\PermissionManager\PermissionManagerServiceProvider" --tag="config"
php artisan vendor:publish --provider="HosseinHezami\PermissionManager\PermissionManagerServiceProvider" --tag="migrations"
php artisan migrate

Or the shortcut, which does the publishing and migration in one shot:

php artisan permission-manager:install --migrate

Then add the trait to your authenticatable model — this is the one step people forget:

namespace App\Models;

use HosseinHezami\PermissionManager\Traits\PermissionTrait;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use PermissionTrait;
}

Where to look first

  1. config/permission-manager.php — read it top to bottom before writing any authorization code. The super-admin bypass, cache TTL, guard list, and audit toggles all live here and they change the semantics of everything downstream.
  2. php artisan permission:doctor — run it immediately after migrating. It validates schema, cache, guard config, and hierarchy integrity. Make it part of onboarding and CI.
  3. The migration set — 14 tables is a lot of surface area. Skim the schema so you know what user_roles, user_permissions, role_inherits, and team_user actually look like before you build queries against them.
  4. php artisan permission:why <user> <permission> and PermissionManager::explain($user, 'orders.delete') — these are the debugging primitives. Learn them on day one, not on the day production breaks.

First use case: lock down one CRUD resource end-to-end

Don't start with ABAC or teams. Prove the RBAC loop on a single resource:

// 1. Seed the permissions from your actual routes
//    php artisan permission:sync-routes

// 2. Create a role and grant a wildcard
$admin = Role::create(['name' => 'Administrator', 'slug' => 'admin']);
$admin->assignPermission('users.*');

// 3. Assign to a user
$user->assignRole('admin');

// 4. Guard the routes
Route::get('/users', [UserController::class, 'index'])
    ->middleware('pm:permission:users.view');
Route::post('/users', [UserController::class, 'store'])
    ->middleware('pm:permission:all:users.view,users.create');

If hasPermissionTo('users.edit') returns true and permission:why gives you a readable chain, the install is healthy. Everything else is incremental.


Implementation Patterns

Pick one enforcement layer and be consistent. This package gives you three: the middleware DSL, Blade directives, and Laravel's Gate. Mixing them arbitrarily is how you get a "we thought that route was protected" incident.

  • Middleware for coarse route-level gating (pm:permission:users.view).
  • Gate/Policy for anything that needs a model instance ($user->can('update', $post)).
  • Blade strictly for presentation. Never treat a hidden button as authorization.

Middleware DSL for route groups. The DSL is terse and supports all four combinations, which maps well onto resource controllers:

Route::middleware('pm:permission:any:users.view,users.list')->group(function () {
    Route::get('/users', [UserController::class, 'index']);
});

Route::middleware('pm:permission:all:users.view,users.create')->group(function () {
    Route::post('/users', [UserController::class, 'store']);
});

Route::middleware('pm:role:admin|manager')->group(function () {
    Route::get('/admin', DashboardController::class);
});

Apply these at the route-group level rather than per-route where possible — it makes the authorization surface auditable in one screen.

Gate integration is the pattern that scales. Wrap the package's engine in a single Gate::before() so your existing policies and $user->can() calls keep working unchanged. That gives you one migration point and leaves you free to swap implementations later:

// AppServiceProvider::boot()
Gate::before(function ($user, string $ability, array $arguments = []) {
    $subject = $arguments[0] ?? null;

    if ($subject && method_exists($user, 'canPermission')) {
        // ABAC path: contextual check against the model instance
        return $user->canPermission($ability, $subject) ?: null;
    }

    return $user->hasPermissionTo($ability) ?: null;
});

Return null (not false) when you don't have an opinion, so Laravel's normal policy resolution still runs for models you haven't modeled in the permission engine.

ABAC for record-level rules, RBAC for feature-level rules. This is the distinction that makes the package worth its weight. A user having posts.update is a feature grant. Whether they can update this post is an attribute question:

// Gate/policy
public function update(User $user, Post $post): bool
{
    return $user->canPermission('posts.update', $post);
}

Keep the attribute rules in the condition engine's JSON (it explicitly avoids eval()), not scattered across controllers. Version-control the conditions alongside your migrations.

Teams: set context once, high in the stack. Team scoping is contextual, so it must be established before any authorization middleware runs:

Route::middleware(['pm.team:header,X-Team-Id'])->group(function () {
    // everything here is scoped to the team in the header
});

Order matters — put pm.team before pm:permission:* in the group array. An unset team context combined with a team-scoped check is the single most common source of "it works locally, it 403s in staging" reports.

Temporary and direct permissions for break-glass access. This is the feature that saves you from creating report-export-temp-jan roles:

$user->givePermissionTo('reports.export', expiresAt: now()->addDay());
$user->hasDirectPermission('reports.export');

// later
$user->revokePermissionTo('reports.export');

Pair every givePermissionTo(..., expiresAt:) in application code with an audit record, so the grant is explainable at review time.

Deny as an exception layer, not a design tool. The precedence chain is explicit and worth memorizing:

1. Super Admin bypass
2. Explicit User DENY
3. Explicit Role DENY
4. Explicit User ALLOW
5. Role ALLOW
6. Inherited Role permissions
7. Default: DENY

The practical use: role grants a broad wildcard, deny carves out the dangerous slices.

$editor->assignPermission('posts.*');
$user->denyPermissionTo('posts.delete');   // deny wins over the wildcard

Seeding: derive permissions, don't hand-maintain them. permission:sync-routes and the resource generator keep the permission table in sync with reality. Run sync in your deploy pipeline as a step after migrate, and commit the resulting role/permission seeding as idempotent seeders.

Testing pattern. Use the provided helpers and reset permission state between tests:

public function test_editor_cannot_delete_posts(): void
{
    $this->actingAsRole('editor');

    $this->assertHasPermission('posts.edit');
    $this->assertFalse(auth()->user()->hasPermissionTo('posts.delete'));

    $this->get('/posts/1')->assertForbidden();
}

Because permission state is cached, use RefreshDatabase (or an explicit cache flush) so a grant in one test doesn't leak into the next.

Debugging workflow, in order:

  1. permission:doctor — is the environment sane?
  2. permission:why [email protected] orders.delete — what does the engine think?
  3. PermissionManager::explain($user, 'orders.delete') — programmatic, loggable, paste into the ticket.
  4. Permission snapshot — the user's complete authorization state, for "works for Alice, not Bob" bugs.
PermissionAudit::byActor($admin->id)->action('granted')->get();

Gotchas and Tips

Wildcards are the biggest footgun. $role->assignPermission('*') grants everything and is easy to leave in a seeder by accident. Likewise !users.delete as a permission string means "everything except this", which reads as a deny but isn't one — it's a negation pattern at match time, and it does not participate in the deny precedence chain the way denyPermissionTo() does. Prefer explicit denyPermissionTo() over ! negation; it's visible in the audit log and in explain() output.

The permission key is the route column. Permission::create(['route' => 'users.edit']) — not name. If you're porting from Spatie, your $permission->name references and any raw queries against permissions.name will silently break. Grep for name after migrating.

Cache invalidation is the #1 production hazard. Grant a permission, then watch a request still 403 because the tagged cache hasn't been busted. Rules of thumb:

  • Never mutate roles/permissions inside a request and then assert in the same request without flushing.
  • Any code path that writes user_roles, user_permissions, or role_inherits must go through the package's API, never raw DB::table() inserts — bypassing the API bypasses invalidation.
  • Include a cache flush in your deploy script when permissions are seeded.

Team context doesn't exist outside HTTP. Queue jobs, Artisan commands, and scheduled tasks have no X-Team-Id header. Any authorization performed in a job must explicitly set the team context first, or you'll get a DENY that looks like a permissions bug but is really a missing context. This is the most likely source of silent background-job failures.

Guard isolation is per-guard, which is either a feature or a surprise. Permissions assigned under web do not apply to api. If you have a token-based API plus a session-based admin panel, plan the guard split up front — retrofitting means re-assigning everything. Also confirm the guards you actually use are registered in the config's guard list; an unlisted guard yields confusing denials rather than an error.

Expiry semantics need verification. givePermissionTo(..., expiresAt:) is advertised as "automatic expiry." Confirm whether expiry is evaluated at query time or whether a scheduled sweep is required. If the latter, register the command in your scheduler — an expired grant that still resolves is a security finding, not a bug.

Audit and authorization-log tables grow unbounded. permission_audits and authorization_logs will accumulate one row per change and (if enabled) per access attempt. Before enabling access logging in production, decide on retention and pruning. On a busy app this table can dwarf everything else within weeks. Enable change auditing everywhere; enable access-attempt logging only where compliance actually demands it.

14 tables is a real migration cost. This isn't a two-table bolt-on. Expect the migration to be the slow step on large databases, and expect schema changes on package upgrades to require attention. Test the upgrade path on a production-sized snapshot before rolling out.

Cycle detection throws. inheritFrom() throws CyclicRoleInheritanceException on circular hierarchy. If you build hierarchies in a seeder from config data, wrap it and fail the deploy loudly rather than catching and continuing — a silently broken hierarchy produces denials that are very hard to trace.

Blade directive casing. The README shows @role, @permission, and @unlesspermission, while the feature list advertises @unlessPermission. Blade custom directives are matched by name and casing is not reliably normalized. Pick whichever form your version registers and standardize on it — a mismatch renders the block always visible or always hidden, and the second failure mode is the dangerous one. Add a smoke test asserting the compiled output for one non-trivial case.

Naming hygiene beats engine sophistication. Decide on a convention (resource.action, lowercase, dot-separated) and enforce it in a lint step or a test that asserts every permission row matches the pattern. The condition engine, wildcards, and explain() are all far easier to reason about when the key space is boring.

Maturity risk is real and should shape your integration. 62 stars, zero dependents, and a single-maintainer release cadence on a package claiming "enterprise-grade" surface area means you are an early adopter. The reported 141 tests / 226 assertions is thin for 14 tables plus ABAC plus a condition engine — that's roughly 1.6 assertions per test across a very wide feature set. Concretely:

  • Keep the Gate::before() seam described above so the whole engine is replaceable behind one provider method.
  • Do not import the package's models directly into your domain layer; wrap access in a small AuthorizationService you own.
  • Write your own integration tests for the specific precedence and team scenarios you rely on, rather than trusting the README's guarantees.
  • Read the relevant source before depending on an edge case. For a package this size, reading src/ is faster than guessing.
  • Don't enable super-admin bypass globally "just in case" — scope it to an explicit config value you can flip per environment, so local convenience never ships to production.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings