Gates

Define gates in AppServiceProvider::boot(); each closure receives the user, then your arguments. Gate::before runs ahead of every gate and policy, and Gate::after once the answer is known:

Gates and hooks in AppServiceProvider::boot()PHP
Gate::before(fn (User $user) => $user->hasRole('admin') ? true : null);
Gate::define('view-dashboard', fn (User $user) => $user->hasRole('staff'));
Gate::define('refund-order', fn (User $user, Order $order) => match (true) {
    ! $user->hasRole('staff') => Response::deny('Only staff can issue refunds.'),
    $order->status !== 'paid' => Response::deny("Order {$order->id} is {$order->status}."),
    default => Response::allow(),
});
Gate::after(fn (User $user, string $ability, mixed $result) => Log::info(
    "gate {$ability} user={$user->name} result=".json_encode(
        $result instanceof Response ? $result->allowed() : $result)));

Gate::allows() and denies() check the current user, Gate::forUser($user) someone else, and any() or none() a list of abilities. In php artisan tinker:

Asking the gates about three usersPHP
[$paid, $refunded] = [App\Models\Order::find(1), App\Models\Order::find(2)];
$refunded->update(['status' => 'refunded']);
foreach (App\Models\User::whereIn('name', ['Ann', 'Sam', 'Ada'])->get() as $user) {
    $gate = Gate::forUser($user);
    printf("%s dashboard=%d refund(1)=%d refund(2)=%d\n", $user->name,
        $gate->allows('view-dashboard'), $gate->allows('refund-order', $paid),
        $gate->allows('refund-order', $refunded));
}
Output
Ann dashboard=0 refund(1)=0 refund(2)=0
Sam dashboard=1 refund(1)=1 refund(2)=0
Ada dashboard=1 refund(1)=1 refund(2)=1

Ada may refund an order twice: before answered true, so the rule never ran; keep before for true superusers. The after hook logged gate refund-order user=Sam result=false and the like. Type its $result as mixed: a rule's Response object arrives there, and the documented ?bool hint throws a TypeError.

A role column suffices while each user has one role. To grant permissions without a deploy, use spatie/laravel-permission 12,967 (https://github.com/spatie/laravel-permission 12,967 ) (MIT; 8.3.0 of 3 July 2026, for Laravel 12 2,157 and 13). It registers its own Gate::before: once Sam held a role with the moderate reviews permission, $sam->can('moderate reviews') returned true, no gate needed.

Hand-rolled roles compared with spatie/laravel-permission
Concern role column spatie/laravel-permission
Storage One string per user Five tables, many roles
Changing a role's rights Edit code, deploy Update rows
Route middleware can: only Adds role:, permission:
Extras None Cache, teams, guards, wildcards