zain-ul-abdain / laravel-route-permissions
Scaffold Laravel permissions from your named routes, then enforce them. Roles, direct permissions, cached lookups, Gate integration, and optional Passport scope sync.
Package info
github.com/zain-ul-abdain/laravel-route-permissions
pkg:composer/zain-ul-abdain/laravel-route-permissions
Requires
- php: ^8.2
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.13
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
Suggests
- laravel/passport: Enables automatic synchronisation of permission names into OAuth token scopes (^13.0).
README
Scaffold your permission catalogue from your named routes, then enforce it. Roles, direct grants, cached lookups, Gate integration, and optional Passport scope sync.
composer require zain-ul-abdain/laravel-route-permissions php artisan migrate php artisan permissions:sync
The idea
You already declare your routes. You already name them. So the permission catalogue can be derived from them instead of hand-maintained in a seeder that drifts out of sync the moment someone adds a controller.
$ php artisan permissions:sync Routes scanned ........... 47 New permissions .......... 3 Orphaned ................. 1 + posts.publish new + posts.archive new + billing.invoices.void new ~ posts.legacy-export no matching route Orphaned permissions left in place. Re-run with --prune to remove them.
That diff is the point. New routes surface as new permissions; deleted routes surface as orphans you decide about. Nothing changes silently.
Why route names, not controller names
A permission has to be anchored to something stable.
Anchoring to the controller class — PostController@edit → post.edit — looks convenient and fails badly: renaming the controller changes the derived string, which orphans every grant referencing the old one. Authorization quietly breaks during an ordinary refactor, with no error and no diff.
Route names are deliberate identifiers. You choose them, they appear in route() calls throughout your app, and changing one is already a conscious act with visible consequences.
So permissions are written to the database once and referenced by name. Enforcement is a dictionary lookup, not string munging at request time.
Usage
Add the trait to your user model:
use Zain\RoutePermissions\Concerns\HasPermissions; class User extends Authenticatable { use HasPermissions; }
Enforcing
// Requires the permission matching this route's name — "posts.edit" Route::put('/posts/{post}', [PostController::class, 'update']) ->name('posts.edit') ->middleware(['auth', 'route.permission']); // Or name the permission explicitly. Multiple = ANY. Route::post('/posts/{post}/publish', ...) ->middleware(['auth', 'permission:posts.publish,posts.manage']); // Roles Route::get('/admin', ...)->middleware(['auth', 'role:admin']);
A guest gets 401. An authenticated user without the permission gets 403. A route with no name throws — failing loudly beats a silent allow.
Granting
$user->assignRole('editor'); $user->assignRole(['editor', 'reviewer']); $user->syncRoles('viewer'); // replaces $user->removeRole('editor'); $user->givePermissionTo('reports.export'); $user->revokePermissionTo('reports.export'); Role::create(['name' => 'editor'])->grant(['posts.edit', 'posts.publish']);
Checking
$user->hasPermissionTo('posts.edit'); $user->hasAnyPermission(['posts.edit', 'posts.publish']); $user->hasAllPermissions(['posts.edit', 'posts.publish']); $user->hasRole('editor'); $user->hasAnyRole(['editor', 'admin']); $user->getRoleNames(); $user->getPermissionNames();
All checks are case-insensitive, consistently — permissions and roles behave the same way.
Gate and Blade
A Gate::before hook is registered by default, so this works with no further wiring:
$user->can('posts.edit'); @can('posts.edit') ... @endcan
It returns null rather than false on a miss, so your existing policies and Gate definitions still resolve normally.
@permission('posts.edit') ... @endpermission @role('admin') ... @endrole @anyrole(['admin', 'editor']) ... @endanyrole
Performance
Resolving a user's effective permissions takes two queries — direct grants, and grants inherited through roles — regardless of how many roles they hold. The result is cached per user and invalidated on every grant or revoke.
'cache' => [ 'enabled' => true, 'store' => null, // default store 'ttl' => 3600, // null = forever, 0 = disabled ],
Passport scopes (optional)
If you use Passport, permission names can be registered as OAuth token scopes:
'passport' => ['sync_scopes' => true],
Passport is a suggest, not a requirement. The integration is guarded three ways: off by default, skipped entirely if Passport isn't installed, and the database read is deferred until after boot and wrapped — so a fresh install can still run php artisan migrate before the tables exist.
Configuration
php artisan vendor:publish --tag=route-permissions-config
Table names, the user model, cache behaviour, a super-admin role, and scan include/exclude patterns are all configurable. Auth scaffolding routes (login, register, password.*) and common package routes (Horizon, Telescope, Sanctum) are excluded from scanning by default.
Why not spatie/laravel-permission?
Mostly, use Spatie. It's excellent, it's battle-tested, and it has features this doesn't — teams, guards, wildcard permissions.
This package exists for one thing Spatie deliberately doesn't do: generating and reconciling the permission catalogue from your routes, with a diff you can review and prune. If you're hand-maintaining a permission seeder and it keeps drifting, that's the gap this fills. If you're not, Spatie is the better default.
Testing
composer install vendor/bin/pest
32 tests. The suite runs against SQLite, PostgreSQL and MySQL — behaviour differs by engine, and a SQLite-only suite will pass while hiding real failures. With Docker:
docker compose run --rm test # sqlite docker compose run --rm test-pgsql # postgres docker compose run --rm test-mysql # mysql
Predecessor
This replaces zainburfat/rbac (2022), which is not maintained and should not be installed. A full audit of that version is in AUDIT.md — 22 findings, five critical, including a route file referencing a class that could never autoload, a database query in boot() that broke artisan migrate on a fresh install, authorization failures returning HTTP 200, and an unauthenticated user-registration endpoint silently added to every consuming application.
This is a rewrite against the same idea, not a patch of that code.
Requirements
PHP 8.2+ · Laravel 12 or 13
License
MIT. Built by Zain Ul Abdain — backend engineer working on payments and authorization infrastructure.