Short version for the impatient: Grant keeps your roles and permissions in PHP enums, and that is a lovely idea right up until a client asks to create a new role from an admin screen. Then it is the wrong tool. If you want the reasoning, read on.
I found Grant the way I find most packages, which is by reading the Laravel News write-up on a slow afternoon. My first reaction was “great, another permissions package”. My second reaction, about four paragraphs later, was to grep one of my older Laravel projects for can(' and count how many raw strings I had scattered around. It was a depressing number.
I’ll be upfront about one thing. I haven’t run Grant on a client project yet. What follows is a close read of the README and the article, plus the questions I would ask before trying it. Treat it as a review, not a field report.
What Grant actually does
Grant is a roles and permissions package by Pushpak Chhajed. The pitch in its README is that permissions are an enum, roles are an enum, and only the assignments live in the database. Every check goes through Laravel’s own Gate.
A permission is a string-backed enum that implements an Ability contract:
enum Permission: string implements Ability
{
case EditPosts = 'edit-posts';
case ViewReports = 'view-reports';
public function deniedMessage(): string
{
return 'You are not allowed to do that.';
}
}
A role is another enum that lists the permissions it holds:
enum Role: string implements RoleContract
{
case Editor = 'editor';
case Viewer = 'viewer';
public function permissions(): array
{
return match ($this) {
self::Editor => [Permission::EditPosts],
self::Viewer => [Permission::ViewReports],
};
}
}
After that you add a HasRoles trait to the user model and call $user->grant(Role::Editor). Checks look like the ones you already write: $user->can(Permission::EditPosts), Gate::authorize(...), @can in Blade, or ->can(...) on a route.
The README makes a claim I like a lot: Grant has no check API of its own, so if you remove it, every can call in your app keeps working. That is a real exit strategy, and most packages in this space can’t say it.
How Spatie does it, and why that still matters
The package most Laravel developers reach for is spatie/laravel-permission. Its README describes it as a way to manage user permissions and roles in a database, and the examples use strings:
$user->givePermissionTo('edit articles');
$user->assignRole('writer');
$user->can('edit articles');
Roles and permissions are rows. That sounds like a small implementation detail, but it changes who is allowed to touch them. A row can be created by a form. An enum case can only be created by someone who commits code and deploys.
I’m fairly sure newer Spatie releases accept backed enums as arguments in many methods, so you can get typed names there too. Check the docs for the version you run. Even then, the rows still live in the database, and that is the actual difference between the two designs.
The typo problem enums fix
Here is the failure mode that made me care about this in the first place. Laravel’s Gate denies any ability it has never heard of. If one file checks edit article and another registers edit articles, you don’t get an exception. You get false, and a user who can’t see a button they should see.
With strings, that bug survives code review because both spellings look plausible. With an enum, it can’t exist. Permission::EditArticle either resolves or your editor draws a red line under it, and PHPStan fails the build if the editor missed it.
The side benefits are boring in the best way. You can rename a case with your IDE and every usage follows. You can open one file and see every permission the application knows about. You can grep for Permission::DeletePosts and find every place that checks it. Try that with 'delete posts' and a codebase where someone also wrote 'delete-posts' once.
Roles get the same treatment, and the match expression in the role enum is a nice touch. The README points out that permissions() returns a plain array, so one role can build on another with a spread:
self::Editor => [
Permission::EditPosts,
...self::Viewer->permissions(),
],
That is inheritance without a database table for it.
Where enums hurt
Now the part the pitch doesn’t dwell on. Changing a role’s backing value, or deleting a case, leaves old assignments orphaned. The Laravel News article says it plainly: those users lose the role until you remap the rows. Grant ships php artisan grant:sync to remap or delete them, and grant:list and grant:show to inspect what is registered.
So a permission rename stops being a one-line change. It becomes a code change plus a data fix, and both have to land in the same deploy. If I used this, grant:sync would go into the deploy script right next to migrate, and I would test the rename on a copy of production data first. A fresh local database will never show you the orphaned rows.
php artisan migrate --force
php artisan grant:sync
php artisan config:cache
There is a second cost, and it is the one that decides most projects. Enum cases are code. If your client’s office manager wants to invent a “Regional reviewer” role on Thursday afternoon, Grant says no, because the role doesn’t exist until a developer writes it. For internal tools with three stable roles, that is a feature. For a SaaS where each customer designs their own roles, it ends the conversation.
Scoped roles and policies
Grant also supports scoping. You pass any Eloquent model as on, and the role applies to that team or project only:
$user->grant(Role::Editor);
$user->grant(Role::Viewer, on: $team);
$user->can(Permission::ViewReports, $team);
One detail in the article made me stop and reread it. A scoped permission check includes the user’s global roles as well as the roles assigned on that model. So a user who is a global Editor is an Editor on every team. That might be exactly what you want. It might also be a surprise during an audit, so I would write a test for it on day one.
Policies keep working for the model-level rules. Grant adds a Requires attribute that checks the permission before your policy method runs, so the method only has to answer the ownership question:
#[Requires(Permission::EditPosts)]
public function update(User $user, Post $post): bool
{
return $post->author_id === $user->id;
}
I like the split. The permission says “this kind of user may edit posts”, and the policy says “and this post is theirs”. Laravel’s own authorization docs describe policies as the place for model-specific rules, so Grant isn’t fighting the framework here.
There is also a super admin option, and only a globally assigned super admin role bypasses Gate checks. A scoped super admin does not, which is the safe default.
How I would decide between them
I wouldn’t pick on taste. I’d ask three questions in this order, and the first one usually settles it.
Who edits roles? If the answer is “a non-developer, in a browser”, use database rows and keep Spatie. If the answer is “the dev team, in a pull request”, Grant becomes interesting.
How many roles will exist in a year? A handful that change twice a year fit in an enum file. Dozens that vary per customer belong in a table.
What does the upgrade path look like? Grant needs PHP 8.3 or later and Laravel 12 or 13, according to its README. If you are still on an older version, that is your answer for now. I wrote about how I plan client upgrades in my Laravel 13 support window post, and the same logic applies here: don’t adopt a package that forces an upgrade you hadn’t scheduled.
I build and maintain Laravel applications for clients, and you can see the kind of work on my portfolio. The pattern I see most often is a small internal tool where three roles will never change, and a developer who still installed a full database-backed permission system because that was the tutorial they followed. Grant is aimed squarely at that case, and I think it is a better default there.
My honest reservation is age. This is a young package. I’d read its issue tracker, check how fast the maintainer answers, and pin the version before trusting it with authorization, which is the one part of an app where a bug becomes an incident.
Something to do this week
You don’t need either package to get the main benefit. Open your project and run a search for can(' and Gate::allows('. Write down every distinct string you find. I’d bet you find at least one pair that differs by a hyphen or a plural.
Move those strings into a single backed enum called Permission, and change the call sites to use the cases. Where a plain string is still needed, pass the case’s ->value so the spelling comes from one place. Then, if you still want roles in code, try Grant on a branch and run grant:list to see what it registered. If the enum file already feels natural, you have your answer.