Grant gives your Laravel app roles and permissions with almost nothing to learn. Permissions are a PHP enum. Roles are a PHP enum. Only the assignments live in the database, and every check goes through Laravel's native Gate:
$user->grant(Role::Editor);
$user->can(Permission::EditPosts);Grant has no check API of its own, so if you ever remove it, every can call in your app keeps working.
Grant requires PHP 8.3+ and Laravel 12 or 13.
composer require shipfastlabs/grant
php artisan grant:install
php artisan migrateThe install command publishes the config and migration, and creates starter app/Enums/Permission.php and app/Enums/Role.php enums if you don't have them yet.
Then add the HasRoles trait to your user model:
use Shipfastlabs\Grant\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}A permission is a string-backed enum that implements Ability. Each case's value is the ability name registered with the Gate.
namespace App\Enums;
use Shipfastlabs\Grant\Ability;
enum Permission: string implements Ability
{
case EditPosts = 'edit-posts';
case DeletePosts = 'delete-posts';
case ViewReports = 'view-reports';
public function deniedMessage(): string
{
return 'You are not allowed to do that.';
}
}A role is a string-backed enum that implements Role and lists the permissions it holds.
namespace App\Enums;
use Shipfastlabs\Grant\Role as RoleContract;
enum Role: string implements RoleContract
{
case Admin = 'admin';
case Editor = 'editor';
case Viewer = 'viewer';
public function permissions(): array
{
return match ($this) {
self::Admin => Permission::cases(),
self::Editor => [Permission::EditPosts, Permission::DeletePosts],
self::Viewer => [Permission::ViewReports],
};
}
}Note
Roles are for assigning, permissions are for checking. Grant intentionally has no role: middleware or @role directive. If you need to gate on a role, give it a permission.
Since permissions returns an array, one role can build on another with a plain spread:
self::Editor => [
Permission::EditPosts,
...self::Viewer->permissions(),
],$user->grant(Role::Editor);
$user->revoke(Role::Editor);
$user->syncRoles([Role::Editor, Role::Viewer]);
$user->hasRole(Role::Admin);
$user->roles(); // Collection<Role>
$user->permissions(); // Collection<Permission>Use Laravel's authorization features exactly as you already do:
$user->can(Permission::EditPosts);
Gate::authorize(Permission::DeletePosts);@can(Permission::EditPosts)
<x-edit-button />
@endcanRoute::put('/posts/{post}', UpdatePostController::class)
->can(Permission::EditPosts);That's it. The rest of this document covers the optional extras.
Pass any Eloquent model as on to scope a role to a team, project, or anything else:
$user->grant(Role::Admin, on: $team);
$user->revoke(Role::Admin, on: $team);
$user->syncRoles([Role::Admin], on: $team);
$user->roles(on: $team);
$user->permissions(on: $team);
$user->can(Permission::EditPosts, $team);A scoped check also includes the user's global roles, so it never grants less than an unscoped check.
Policies keep owning model-level rules like ownership. Grant supplies the capability, your policy adds the context:
public function update(User $user, Post $post): bool
{
return $user->can(Permission::EditPosts)
&& $post->author_id === $user->id;
}To skip the repeated check, add the Requires attribute. The Gate denies the action unless the user holds the permission, then runs your method:
use Shipfastlabs\Grant\Requires;
#[Requires(Permission::EditPosts)]
public function update(User $user, Post $post): bool
{
return $post->author_id === $user->id;
}Gate arguments are forwarded, so #[Requires] respects roles scoped to the policy's model.
Pick one role in config/grant.php to pass every Gate check:
'super_admin' => App\Enums\Role::Admin,Set it to null to turn the bypass off. Only a globally granted role bypasses the Gate; the same role granted on: $team behaves like any other role.
Only a role's backing value is stored. Renaming a case, or any permission, needs no data changes. Changing a role's value or deleting a case leaves orphaned rows, which Grant ignores, so those users quietly lose the role.
Run grant:sync after changing the enum to remap or delete them:
php artisan grant:sync Stored role [admin] no longer exists. What should its 12 assignments become?
› administrator
editor
viewer
Delete them
With --no-interaction it only reports orphans and exits with a failure status, which makes it a handy deploy check.
Grant loads a user's assignments once per request and answers every later can, hasRole, roles, and permissions call from memory. Authorizing fifty models in a loop costs one query.
The memo is cleared per request (and per job in queue workers, and under Octane). Granting, revoking, syncing, and saving or deleting a RoleAssignment clear it for you. Only bulk query-builder writes bypass it, so flush manually after those:
use Shipfastlabs\Grant\Facades\Grant;
Grant::flush($user); // one user
Grant::flush(); // everyoneThe facade mirrors the assignment methods for places where calling the model is awkward. It never performs checks.
Grant::grant($user, Role::Editor);
Grant::revoke($user, Role::Editor);
Grant::syncRoles($user, [Role::Viewer]);
Grant::hasRole($user, Role::Editor);config/grant.php has four options:
return [
'roles' => App\Enums\Role::class,
'permissions' => App\Enums\Permission::class,
'super_admin' => null,
'model' => Shipfastlabs\Grant\Models\RoleAssignment::class,
];To add your own relationships or scopes, extend Shipfastlabs\Grant\Models\RoleAssignment and point model at your subclass.
If your users or scoped models use UUID or ULID keys, edit the published migration before running it: switch foreignId to foreignUuid or foreignUlid, and nullableMorphs to nullableUuidMorphs or nullableUlidMorphs.
Grant supports a single user model: the one set in auth.providers.users.model.
To publish resources individually:
php artisan vendor:publish --tag=grant-config
php artisan vendor:publish --tag=grant-migrations# List every permission registered with the Gate...
php artisan grant:list
# Show a user's roles and resolved permissions...
php artisan grant:show 1
php artisan grant:show 1 --on='App\Models\Team:5'
# Remap or delete stored roles that no longer match the enum...
php artisan grant:syncTo add a permission or role, add a case to the enum. For a new role, map its permissions in Role::permissions.
Grant ships no admin panel plugin. Filament already handles backed enums, so a Select on your user resource is enough:
Select::make('roles')
->multiple()
->options(Role::class)
->dehydrated(false)
->afterStateHydrated(fn (Select $component, ?User $record) => $component->state(
$record?->roles()->map->value->all() ?? [],
))
->saveRelationshipsUsing(fn (User $record, array $state) => $record->syncRoles(
array_map(Role::from(...), $state),
)),Implement Filament's HasLabel on your role enum to control the labels.
Add a factory state to create users with roles:
public function role(Role $role, ?Model $on = null): static
{
return $this->afterCreating(fn (User $user) => $user->grant($role, $on));
}$editor = User::factory()->role(Role::Editor)->create();For fluent assertions, use the InteractsWithGrant trait in your base test case:
use Shipfastlabs\Grant\Testing\InteractsWithGrant;
abstract class TestCase extends BaseTestCase
{
use InteractsWithGrant;
}$this->actingAs($user)
->assertCannot(Permission::EditPosts)
->withRole(Role::Editor)
->assertCan(Permission::EditPosts);composer testPlease read the contribution guide first. For security issues, see the security policy.
Grant is maintained by Shipfastlabs and released under the MIT license.
