Laravel policies and gates are the two ways Laravel answers one question, is this user allowed to do this. A gate is a named closure for a one-off check that has no model behind it. A policy is a class that groups every rule for a single model, like Post or Invoice.

Most Laravel apps do not fail authorization loudly. They leak it quietly, through an inline if block in a controller that nobody updated when a new role shipped.

You have probably written if ($post->user_id!== auth()->id()) { abort(403); } more than once, in more than one place. That works right up until the rule changes in three files and you only remember two of them. This guide covers when to reach for a gate, when to reach for a policy, how to wire both into controllers, Blade views, and API routes, and which mistakes send a 403 to exactly the wrong person. We start with the difference, move into working code for Laravel 11 and 12, then finish with the checks that keep authorization honest.

Key TakeawaysGates are closures registered by name and fit abilities with no model behind them, like access-admin-panel.Policies are classes in app/Policies that group model rules, and Laravel discovers PostPolicy for Post automatically.In Laravel 11 and 12 the base controller no longer includes AuthorizesRequests, so $this->authorize() fails until you add the trait or call Gate::authorize() instead.Hiding a button with @can is not authorization. The route or controller behind that button still needs its own check.Policy methods typed as ?User $user also run for guests, which is how public read access works without a second code path.

What Laravel policies and gates actually do

Authentication asks who you are. Authorization asks what you are allowed to do once you are in. Laravel policies and gates both answer the second question, and both resolve through the same Gate facade underneath.

A gate is a closure registered under an ability string. You hand it a name like access-admin-panel, it receives the current user, and it returns true or false. Nothing about it is tied to a record.

A policy is a plain PHP class that maps method names to abilities for one model. PostPolicy::update() answers whether this specific user can update this specific post. Same question, different shape.

Question

Gate

Policy

What it is

A named closure

A PHP class

Where it lives

AppServiceProvider::boot()

app/Policies

Tied to a model

No

Yes, one model per policy

Best for

App wide abilities

Per record ownership rules

Typical call

Gate::allows('access-admin-panel')

$user->can('update', $post)

The official Laravel authorization documentation treats them as two doors into one system, which is why $user->can() works for both without you thinking about it.

How Laravel gates work

Register a gate in the boot() method of a service provider. Laravel 11 removed AuthServiceProvider from the default skeleton, so AppServiceProvider is now the usual home.

use App\Models\User;
use Illuminate\Support\Facades\Gate;

// app/Providers/AppServiceProvider.php
public function boot(): void
{
 Gate::define('access-admin-panel', function (User $user) {
 return $user->role === 'admin';
 });
}

Calling it is deliberately boring. Use allows() and denies() when you want a boolean, and authorize() when you want Laravel to throw for you.

use Illuminate\Support\Facades\Gate;

if (Gate::allows('access-admin-panel')) {
 // the current user passes
}

// Throws AuthorizationException, which Laravel renders as a 403
Gate::authorize('access-admin-panel');

One gate feature earns its keep on almost every project. Gate::before() runs ahead of every other check, which is how you give a super admin a skeleton key without sprinkling role checks through twenty policy methods.

Gate::before(function (User $user, string $ability) {
 if ($user->role === 'super-admin') {
 return true;
 }

 return null;
});

Returning null matters. It tells Laravel to keep evaluating the normal checks instead of treating the absent answer as a denial. Returning false here would lock everyone out of everything, which is a fun afternoon of debugging. Want to see this pattern applied to a whole app rather than a snippet? See StackMivo in action on a Laravel project you already know.

How Laravel policies work with a real policy example

Policies are where most real apps spend their authorization budget. Generate one with the model flag so Artisan stubs out the standard method names for you.

php artisan make:policy PostPolicy --model=Post

That gives you viewAny, view, create, update, delete, restore, and forceDelete. Delete the ones you do not need. If Artisan flags are a regular part of your day, the php artisan make:model cheatsheet covers the generator options worth memorising.

Here is a working policy for a blog post, trimmed to the methods that carry real rules.

namespace App\Policies;

use App\Models\Post;
use App\Models\User;

class PostPolicy
{
 public function viewAny(?User $user): bool
 {
 return true;
 }

 public function view(?User $user, Post $post): bool
 {
 return $post->published || $user?->id === $post->user_id;
 }

 public function create(User $user): bool
 {
 return $user->hasVerifiedEmail();
 }

 public function update(User $user, Post $post): bool
 {
 return $user->id === $post->user_id;
 }

 public function delete(User $user, Post $post): bool
 {
 return $user->id === $post->user_id;
 }
}

Read view() closely. The nullable user type and the nullsafe operator let a guest read a published post while an author reads their own drafts, in one method, with no duplicate route for the public case.

Registration is usually free. Laravel discovers App\Policies\PostPolicy for App\Models\Post by naming convention. If your models live somewhere unusual, register the pair explicitly with Gate::policy(Post::class, PostPolicy::class) inside AppServiceProvider::boot().

Gate or policy, which one to reach for

The decision takes about five seconds once you know the tell. Ask whether the check needs a specific record to answer.

  • No record needed, use a gate. Viewing the admin panel, exporting the full customer list, toggling a feature flag.

  • A record decides it, use a policy. Editing this invoice, deleting this comment, approving this timesheet.

  • The model has more than two rules, use a policy even if a gate would technically work. Grouped rules stay readable.

  • The rule crosses every model, use Gate::before() rather than repeating a role check in each policy.

A freelance Laravel developer building a client portal started with ownership checks inline, one in the controller, one in the Blade view, one in an export job. The client then asked for an editor role who could update posts but not delete them. The rule now had to change in three files that had drifted apart, and the export job kept letting the wrong people through for a week. Moving everything into a single PostPolicy turned that into one class and one diff.

Tenancy raises the stakes. Once an app serves multiple customers from one database, a missed ownership check is not a bug, it is a data leak, which is why policies belong in the first sprint of any multi-tenant SaaS build rather than the cleanup sprint.

Enforcing Laravel policies and gates across your app

Defining rules is half the job. Calling them in every entry point is the other half, and Laravel gives you four places to do it.

In controllers

Here is the change that trips up most upgrades. Laravel 11 and 12 ship an empty abstract base controller, so AuthorizesRequests is no longer included and $this->authorize() throws a method error until you add it back.

// app/Http/Controllers/Controller.php
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;

abstract class Controller
{
 use AuthorizesRequests;
}

With the trait in place, the call reads the way you expect.

public function update(UpdatePostRequest $request, Post $post)
{
 $this->authorize('update', $post);

 $post->update($request->validated());

 return redirect()->route('posts.show', $post);
}

Prefer not to touch the base class? Gate::authorize('update', $post) does the same thing from anywhere, including jobs, listeners, and console commands.

In routes

The can middleware alias is registered by default and resolves the route parameter for you, so the check happens before your controller even boots. The Laravel middleware documentation covers how the alias is wired.

Route::put('/posts/{post}', [PostController::class, 'update'])
 ->middleware('can:update,post');

In Blade views

Directives keep the interface honest by hiding actions a user cannot take.

@can('update', $post)
 <a href="{{ route('posts.edit', $post) }}">Edit post</a>
@endcan

In API responses

For requests that expect JSON, a failed check returns a 403 with a message payload automatically. You can control that message from the policy itself, which beats a generic denial when users need to know why they were blocked.

use Illuminate\Auth\Access\Response;

public function update(User $user, Post $post): Response
{
 return $user->id === $post->user_id? Response::allow(): Response::deny('You can only edit posts you created.');
}

When even acknowledging a record is a leak, return Response::denyAsNotFound() and the caller gets a 404 instead. Permission heavy apps such as a Laravel CRM with team pipelines lean on that distinction constantly, because a 403 confirms the deal exists.

Common Laravel authorization mistakes

Five failures show up again and again in code review, and every one of them is cheap to prevent.

  1. Hiding the button and calling it done. A @can block controls pixels, not routes. Anyone with curl still reaches the endpoint.

  2. Passing an instance to viewAny. Index methods have no record yet, so the call is Gate::authorize('viewAny', Post::class) with the class name.

  3. Typing the user as required when guests are allowed. Without ?User $user, Laravel denies guests before your method runs, and public pages quietly break.

  4. Checking the owner but not the tenant. Ownership and tenancy are separate questions, and an admin of company A is not an admin of company B.

  5. Never testing the denial path. Most suites prove the happy path works and never prove the wrong user is stopped.

That last one is worth a test per policy method. Pest keeps them short enough that nobody skips writing them, and the Pest testing guide covers the syntax if it is new to you.

use App\Models\Post;
use App\Models\User;

it('blocks a user from updating a post they do not own', function () {
 $owner = User::factory()->create();
 $intruder = User::factory()->create();
 $post = Post::factory()->create(['user_id' => $owner->id]);

 $this->actingAs($intruder)
 ->put(route('posts.update', $post), ['title' => 'Hijacked'])
 ->assertForbidden();
});

A team lead at a mid-size SaaS found this the hard way. Their billing dashboard hid the cancel button from non owners, the policy was correct, and the API route had no middleware on it at all. A support engineer discovered it while testing a customer report. The fix took ten minutes. Writing a forbidden path test for every policy method took an afternoon, and it has caught two regressions since.

Writing that scaffolding by hand is repetitive work, and repetitive work is where checks get skipped. This is the kind of thing Laravel-native intelligence handles well, because it produces production-ready Laravel apps with the Policies, FormRequests, and Pest tests generated together rather than bolted on later. Describe the ownership rules in plain English and Try StackMivo Now on a model you already have.

Putting Laravel policies and gates to work

Laravel policies and gates give you one place to answer every permission question your app asks. Gates cover broad abilities that stand on their own. Policies cover per record rules that follow a model everywhere it appears, from a Blade button to an API endpoint to a queued job.

Start small. Pick the model with the messiest inline checks, run php artisan make:policy, move the rules across, then delete the old conditionals one controller at a time. Add the AuthorizesRequests trait to your base controller so $this->authorize() works, put can middleware on the routes that matter, and write one Pest test per policy method that asserts a 403 for the wrong user.

Do that and authorization stops being something you remember to check. It becomes something the framework checks for you, in one readable file, every single time.