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.

Maintainers

Package info

github.com/zain-ul-abdain/laravel-route-permissions

pkg:composer/zain-ul-abdain/laravel-route-permissions

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-30 00:16 UTC

This package is auto-updated.

Last update: 2026-08-30 00:37:51 UTC


README

Scaffold your permission catalogue from your named routes, then enforce it. Roles, direct grants, cached lookups, Gate integration, and optional Passport scope sync.

tests license

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@editpost.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.

Portfolio · GitHub · LinkedIn