rennokki/guardian
Guardian is a Laravel package for managing roles and permissions. Define abilities, attach them to roles or users, and check access in routes, controllers, middleware, and Blade views through a clean, expressive API that keeps authorization logic simple, readable, and centralized.
Guardian is a small, trait-based permission layer for Eloquent models. It is not a full authorization framework — it gives you permissions and roles as models, attaches them polymorphically to any model, and exposes short check/attach methods. The fastest path is to treat it as the storage/check layer and keep Laravel's Gate as the thing your app calls.
Minimal setup:
composer require rennokki/guardian
php artisan vendor:publish # publish the package config and migrations
php artisan migrate
Add the traits to the models that should own permissions and roles:
use Rennokki\Guardian\Traits\HasPermissions;
use Rennokki\Guardian\Traits\HasRoles;
class User extends Authenticatable
{
use HasPermissions, HasRoles;
}
If you use a custom Role model, it needs HasPermissions too, because roles are just another permission-bearing model. Point the package config at your models and table names before you run migrations in production.
First use case — seed a permission, attach it, and check it:
use Rennokki\Guardian\Models\Permission;
$permission = Permission::create([
'name' => 'Edit articles',
'slug' => 'articles.edit',
]);
$user->attachPermission($permission);
$user->hasPermission('articles.edit'); // true
Then gate a route or controller:
abort_unless(auth()->user()->hasPermission('articles.edit'), 403);
Where to look first in the repo: src/Traits/HasPermissions.php and src/Traits/HasRoles.php. They are short and are the real API surface — method names, whether checks traverse roles, and what gets cached live there. Read those before trusting any README example.
Use slugs in code, names in UI. A permission's name is for humans ("Edit articles"); its slug is the stable contract (articles.edit). Put slugs in constants or an enum so you are not typing strings across controllers, seeders, and tests.
Seed permissions and roles, attach at runtime. Permissions are reference data. Create them in a seeder and make the seeder idempotent (firstOrCreate on slug). Attach/detach to users in application flows — registration, admin screens, imports.
Roles are bundles; direct permissions are exceptions. The cleanest day-to-day model is: assign roles for the common case, attach direct permissions sparingly for one-off grants. Decide early whether hasPermission() on your version checks both direct and role-derived permissions, and write one test that proves it. If it only checks direct permissions, create a small wrapper method on your User model rather than scattering two checks everywhere.
Wire it into Gate, not into every controller. This keeps can(), policies, Blade, and middleware working normally:
// AppServiceProvider::boot()
Gate::before(function ($user, $ability) {
if ($user->hasRole('super-admin')) {
return true;
}
return $user->hasPermission($ability) ? true : null;
});
Returning null (not false) lets normal policies decide when no Guardian permission matches. Then $user->can('articles.edit'), @can('articles.edit'), and $this->authorize('articles.edit') all work.
Sync on form saves. An admin screen that edits a user's roles should compute the desired set and call the package's sync-style method rather than diffing attach/detach by hand. If your version only has attach/detach, wrap them in a syncRoles(array $slugs) helper on the model so duplicate pivot rows and stale grants do not creep in.
Attach to the right model. Guardian's polymorphic pivot is useful for teams, organizations, or projects: a Team can use HasPermissions and hold its own grants, while users hold roles. Do not assume permissions are globally user-scoped — check the config's morph settings and decide what "permission owner" means in your domain.
Eager load for list screens. An admin table showing each user's roles will otherwise issue a query per row:
User::with('roles', 'permissions')->paginate();
Test the authorization boundary, not the package. Use RefreshDatabase, seed the permission catalogue once, and assert HTTP outcomes:
$user = User::factory()->create();
$user->attachPermission('articles.edit');
$this->actingAs($user)
->get("/articles/{$article->id}/edit")
->assertOk();
Also assert the negative case. Permission bugs are almost always "someone can do something they should not", and a missing assertForbidden is how that ships.
Stale relations after attach/detach. If permission checks read a loaded relation, the in-memory model can keep saying "no" immediately after you grant access in the same request. Call $user->refresh(), unsetRelation('permissions'), or re-fetch the user after mutating grants.
Duplicate pivot rows. Repeated attachPermission() calls can create duplicates depending on the implementation. Prefer sync methods or guard with a hasPermission() check before attaching, and add a unique index on the pivot as a backstop.
Deleting permissions leaves orphans. Deleting a Permission record does not necessarily clean the polymorphic pivot. Use a foreign-key cascade where possible, or delete pivot rows in a model observer. Orphaned grants are invisible until a slug is reused, at which point someone silently regains access.
Slug collisions and typos. A typo in a slug fails closed (usually a 403), which looks like a permission bug rather than a string bug. Centralize slugs, and log or assert when a permission lookup misses during development.
Gate::before bypasses policies. If you return true from Gate::before for any matching ability, policy methods for that ability never run. Keep the super-admin escape hatch explicit and narrow, and do not let a permission slug shadow a policy ability with stricter rules.
Multi-tenancy is on you. Permissions are attached to models, not to tenants. In a multi-tenant app, attach grants to the tenant-scoped model (or include tenancy in the slug) rather than granting a global user permission that leaks across tenants. This is the most common way small permission packages become security incidents.
Low adoption means you own the edge cases. This package has essentially no dependents and limited external scrutiny. That is fine for a small internal permission table, but wrap it: keep Guardian calls behind a User::can... method or the Gate integration above so you can swap the storage layer without rewriting controllers. Pin the version in composer.json, and read the trait source when upgrading — there is no large community to surface breaking changes for you.
Config model swapping is a one-way door. If you publish config and later point it at custom Permission/Role models or renamed tables, existing pivot data does not follow automatically. Decide on custom models and table names before production data exists.
Middleware ordering. Whatever route protection you use, authentication must run before authorization. An unauthenticated request hitting a permission check should get a redirect to login, not a 403 — verify the order in app/Http/Kernel.php.
How can I help you explore Laravel packages today?