Authorization with policies and gates: a practical guide
In any production application, controlling who can do what is as critical as database schema design or error handling. Laravel's authorization system—built on policies and gates—gives us a clean, testable way to enforce these rules across controllers, API endpoints, and views. This guide walks through the practical patterns we use daily to keep authorization logic maintainable and secure.
Why authorization matters in real systems
We've all seen code where permission checks scatter across controllers, become redundant, and eventually contradict each other. A user edits their profile in one place but can't delete a related record in another because the check was missed. Authorization with policies and gates eliminates this sprawl by centralizing rules in one place, then invoking them consistently.
In a typical SaaS application—say, a project management tool—a user should view only their own tasks, but a project manager can update tasks assigned to their team, and only admins delete archived projects. Without a structured approach, these rules become scattered. With policies and gates, they're explicit, testable, and reusable.
Policies per model: view, update, delete
A policy is a class that groups authorization logic for a specific model. Laravel convention pairs them one-to-one with models, making the relationship obvious.
Let's say we have a Task model. We generate its policy:
php artisan make:policy TaskPolicy --model=TaskThis creates app/Policies/TaskPolicy.php with stub methods. Here's what a real-world policy looks like in a project management system:
<?php
namespace App\Policies;
use App\Models\Task;
use App\Models\User;
class TaskPolicy
{
public function view(User $user, Task $task): bool
{
// User can view only if they created it or are assigned to it
return $user->id === $task->user_id || $task->assignees()->where('user_id', $user->id)->exists();
}
public function update(User $user, Task $task): bool
{
// Only creator or assignee can update
return $user->id === $task->user_id || $task->assignees()->where('user_id', $user->id)->exists();
}
public function delete(User $user, Task $task): bool
{
// Only creator can delete
return $user->id === $task->user_id;
}
public function restore(User $user, Task $task): bool
{
return $user->id === $task->user_id;
}
public function forceDelete(User $user, Task $task): bool
{
// Admins can force-delete
return $user->isAdmin();
}
}
Notice the pattern: each method receives the authenticated user and the model instance, then returns a boolean. The policy doesn't throw exceptions—the controller or middleware handles that.
We must register policies in app/Providers/AuthServiceProvider.php:
protected $policies = [
Task::class => TaskPolicy::class,
];
Or use the Gate::guessPolicyNamesUsing() callback for auto-discovery if you follow naming conventions.
In a controller, we invoke the policy:
public function update(Request $request, Task $task)
{
$this->authorize('update', $task);
$task->update($request->validated());
return response()->json($task);
}
If authorization fails, Laravel throws AuthorizationException, which renders a 403 response. In API contexts, we catch it in our exception handler.
Gates for non-model actions
Some actions don't bind to a specific model instance. Approving a bulk export, creating a new team, or accessing an admin dashboard aren't about "who can do something to this object"—they're about "who can do this action at all."
Gates handle these cases. We define them in AuthServiceProvider:
public function boot(): void
{
Gate::define('approve-exports', function (User $user) {
return $user->hasRole('manager');
});
Gate::define('access-admin-panel', function (User $user) {
return $user->isAdmin();
});
Gate::define('create-team', function (User $user) {
return $user->subscription_active && $user->teams_remaining > 0;
});
}
Notice gates receive the user and any other context we pass. They return a boolean just like policies.
In a controller:
public function bulkApproveExports(Request $request)
{
$this->authorize('approve-exports');
// Process bulk approval
}
Or use the Gate::allows() static method when we don't throw an exception:
if (Gate::allows('create-team')) {
$user->teams()->create([...]);
} else {
return response()->json(['error' => 'Team limit reached'], 403);
}
We can also use Gate::denies() for the inverse check.
$this->authorize() in controllers
The $this->authorize() method is the backbone of controller authorization. It's a trait method available in all Laravel controllers.
For model policies, pass the action name and model instance:
public function show(Task $task)
{
$this->authorize('view', $task);
return response()->json($task);
}
public function destroy(Task $task)
{
$this->authorize('delete', $task);
$task->delete();
return response()->noContent();
}
For gates, pass only the gate name:
public function adminDashboard()
{
$this->authorize('access-admin-panel');
return view('admin.dashboard');
}
By default, $this->authorize() throws AuthorizationException if denied. In API controllers, register an exception handler:
use Illuminate\Auth\Access\AuthorizationException;
public function render($request, Throwable $exception)
{
if ($exception instanceof AuthorizationException) {
return response()->json(['message' => 'Unauthorized'], 403);
}
return parent::render($request, $exception);
}
A real-world flow in a SaaS application might look like:
public function updateTask(Request $request, Task $task)
{
// Authorize before validation for security and performance
$this->authorize('update', $task);
$validated = $request->validate([
'title' => 'required|string|max:255',
'status' => 'required|in:pending,in_progress,done',
]);
$task->update($validated);
return response()->json($task);
}
Always authorize before processing—don't validate an unauthorized request, which wastes resources and may leak information.
@can in Blade templates
Authorization extends into the view layer. Show or hide UI elements based on what the user can do using the @can directive:
@can('view', $task)
<div>
<h3>{{ $task->title }}</h3>
<p>{{ $task->description }}</p>
</div>
@endcan
For gates:
@can('approve-exports')
<button class="btn btn-primary">Approve Bulk Export</button>
@endcan
Use @cannot to show an alternative:
@can('delete', $task)
<button class="btn btn-danger">Delete</button>
@else
<p class="text-muted">Only the creator can delete this task.</p>
@endcan
In loops, check permissions efficiently:
@foreach($tasks as $task)
<tr>
<td>{{ $task->title }}</td>
<td>
@can('update', $task)
<a href="{{ route('tasks.edit', $task) }}">Edit</a>
@endcan
@can('delete', $task)
<form action="{{ route('tasks.destroy', $task) }}" method="POST" class="inline">
@csrf @method('DELETE')
<button type="submit">Delete</button>
</form>
@endcan
</td>
</tr>
@endforeach
A note on security: @can is for UX—hiding buttons users can't use. Never rely on it alone. Always authorize in the controller. A determined user can inspect HTML, remove hidden elements, or craft API requests directly.
Common mistakes
Forgetting to authorize before processing: We sometimes authorize after validation or database queries. Move authorization to the top of every action.
Mixing authorization with business logic: Policies should answer "can this user?" not "should this business rule apply?" Keep them separate. If a task is overdue, that's business logic, not authorization.
Overly complex policy methods: If a policy method grows beyond 10 lines, extract helper methods or move logic to the model. Policies should be readable at a glance.
Ignoring guest users: In a policy, if no user is authenticated, Laravel passes null as the user. Handle it:
public function view(?User $user, Task $task): bool
{
return $user && ($user->id === $task->user_id || ...);
}
Not testing authorization: Authorization logic is security-critical. Write tests for each policy method and gate:
public function test_user_can_view_own_task()
{
$user = User::factory()->create();
$task = Task::factory()->create(['user_id' => $user->id]);
$this->assertTrue($user->can('view', $task));
}
public function test_user_cannot_view_others_task()
{
$user = User::factory()->create();
$other = User::factory()->create();
$task = Task::factory()->create(['user_id' => $other->id]);
$this->assertFalse($user->can('view', $task));
}
Key takeaways
- Policies centralize model-level authorization: Group rules by model, register in AuthServiceProvider, invoke with
$this->authorize(). - Gates handle action-level authorization: Use for non-model actions like admin access or bulk operations, defined in AuthServiceProvider.
- Always authorize in controllers first: Before validation, database queries, or view rendering. Throw exceptions consistently.
- Use @can in views for UX only: It hides UI elements but doesn't prevent direct requests. Controller authorization is the real security layer.
- Keep authorization logic simple and testable: Complex checks in policies become hard to maintain. Extract helper methods and write tests.
- Handle guest users explicitly: Policies receive
nullfor unauthenticated requests. Check before accessing user properties.
Wrapping up
Authorization with policies and gates is one of those features that pays dividends immediately. The first time we refactor authorization rules in one place instead of hunting through a dozen controllers, we'll feel the value. Combined with testing, this approach keeps our applications secure and maintainable as they grow.






Comments
Loading comments…