Gate & middleware
With register_gate_check on (the default), permissions resolve through Laravel’s Gate, so the native can: middleware works out of the box:
Route::get('/users', UsersController::class)->middleware('can:auth.users.view');
$user->can('auth.users.view'); // true when granted directly or via a role
Gate::forUser($user)->allows('auth.users.view');How the hook decides
The package registers a single Gate::before hook. It answers only when all of the following hold — otherwise it returns null and your own gates and policies run untouched:
- The check carries no arguments — no model and no extra parameters — or, with assignments on, exactly one PermissionScope (see Scoped checks below).
- The user model has hasPermissionTo(), i.e. it uses HasRoles.
- The ability is the name of a registered permission.
When the user holds that permission — directly or via a role — the hook returns true. It never returns false: a user without the permission falls through to your gates, and with none defined Laravel denies, so the can: middleware responds with 403.
Policies keep the final say
Permission checks never override model policies. As soon as a check carries a model or any argument — $user->can('update', $post), or the can:update,post middleware — the hook defers to your gate or policy. A permission named update authorizes $user->can('update'), but PostPolicy::update() still decides $user->can('update', $post):
Permissions::permission('update');
Permissions::for($user)->givePermissionTo('update');
$user->can('update'); // true — argument-less, answered by the permission
$user->can('update', $post); // PostPolicy::update() decides — ownership, tenancy, …final class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $post->owner_id === $user->getKey();
}
}Coarse permissions and model-scoped policies coexist without one silently swallowing the other.
Scoped checks
With assignments on, pass Permissions::scope() as the single Gate argument to check inside a project, team or tenant — several scopes mean any of them. It works with can(), Gate::authorize() and @can; there are no new Blade directives:
use RoundlyConsulting\Permissions\Facades\Permissions;
$user->can('posts.edit', Permissions::scope($project));
Gate::authorize('posts.edit', Permissions::scope([$team, $parentTeam])); // any of them@can('posts.edit', Permissions::scope($project))
<button>Edit post</button>
@endcanThe hook still answers only true or null, so your gates can still allow. No policy can bind to PermissionScope, so a scoped check never collides with one; any other argument shape goes to your gates and policies untouched.
An ambient scope
Scope-less checks — the can: middleware, @can('posts.edit'), $user->can('posts.edit') — stay unscoped, so a scoped grant never satisfies them. Register a resolver to give them an ambient scope, such as the current team or project:
// app/Providers/AppServiceProvider.php → boot()
use Illuminate\Contracts\Auth\Access\Authorizable;
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Permissions\Facades\Permissions;
Permissions::resolveScopeUsing(
fn (Authorizable $user, string $ability): Model|iterable|null => $user->currentProject,
);
// can:posts.edit, @can('posts.edit') and $user->can('posts.edit') now check inside it
Permissions::resolveScopeUsing(null); // remove it againReturning null or an empty list keeps the check unscoped. The resolver applies to the Gate only, never to hasPermissionTo() or hasRole(), and lives on a container singleton, so Permissions::fake() keeps it. teams-for-laravel registers one for the current team when its permissions bridge is on.
Route middleware with a scope
EnsurePermission checks a permission inside the scope a route parameter is bound to. No alias is registered — reference the class:
use RoundlyConsulting\Permissions\Http\Middleware\EnsurePermission;
Route::put('/projects/{project}/posts/{post}', UpdatePost::class)
->middleware(EnsurePermission::using('posts.edit', scope: 'project'));
// the same, as a string
Route::put('/projects/{project}/posts/{post}', UpdatePost::class)
->middleware(EnsurePermission::class.':posts.edit,project');
// unscoped
Route::get('/admin', AdminController::class)
->middleware(EnsurePermission::using('admin.access'));The scope parameter must be an implicitly bound model; a missing or unbound one throws PermissionException — a route bug, never a silent allow or deny. A guest, a user without HasRoles or a missing permission gets an AuthorizationException (403). It asks the registry directly, not the Gate, so it works with register_gate_check off and ignores resolveScopeUsing() — use the can: middleware for that.
Disabling the hook
Set register_gate_check to false to skip the hook entirely — scoped checks included — and resolve permissions yourself:
// config/permissions.php
'register_gate_check' => false,
// then check explicitly where you need it
abort_unless($request->user()->hasPermissionTo('auth.users.view'), 403);Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.