Laravel 13 Authorization: Gates and Policies, from Zero to Production
Authentication tells you who the user is; authorization decides what they can do. In Laravel 13, Gates and Policies centralize those permissions in the framework and put an end to user_id checks scattered across your controllers.
Authentication vs. Authorization: Two Different Layers
What Each Layer Solves and Why They Get Confused
Authentication answers the question "who are you?": it validates the user's credentials and opens a session or issues a token. Authorization answers a different question, "what can you do?", and it runs afterward, on an already confirmed identity. Mixing them up is where most permission bugs come from: you can have a perfect login and still let any user delete someone else's posts. In Laravel 13 both concepts live in separate, complementary layers, just like in the apps you have already built with Sanctum for tokens or with passkeys for passwordless login.
Laravel 13's Model: Gates and Policies, Like Routes and Controllers
The framework offers two tools for authorization, and the official docs compare them to two pieces you already know: Gates are like routes and Policies are like controllers. Gates define permissions with simple closures, ideal for one-off or global checks; Policies are classes that group permission logic around a concrete model, such as Post, Comment, or User. Both coexist and rely on the same checking mechanisms, so choosing one does not lock you out of the other.
Gates: Quick Permissions with Closures
Defining a Gate with Gate::define in AppServiceProvider
A Gate is registered with Gate::define, usually in the boot method of AppServiceProvider. It receives an ability name and a closure that takes the authenticated user and, optionally, any arguments it needs to decide:
Read also
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::define('edit-post', function (User $user, Post $post) {
return $user->id === $post->user_id;
});
}That closure must return true or false. If you don't care about the authenticated user (for example, a global permission to access a dashboard), you can omit it as the first parameter and Laravel injects it anyway when available.
Checking Permissions: allows(), denies(), and authorize()
Once the Gate is registered, you have three ways to check it. Gate::allows() and Gate::denies() return a boolean without throwing exceptions, perfect for conditionals; Gate::authorize() throws an AuthorizationException that Laravel turns into a 403 response when it fails:
use Illuminate\Support\Facades\Gate;
if (Gate::allows('edit-post', $post)) {
// show the edit button
}
Gate::authorize('edit-post', $post); // 403 if not allowed
if ($user->can('edit-post', $post)) {
// the User model's can() method is the same mechanism
}The can() method on the User model is a shortcut that delegates to the same Gate, so you can use it anywhere you have the user, including models and services.
Gate::before and Gate::after for Global Permissions (Admin)
When a role needs access to everything without defining every ability, Gate::before runs before any other check. It should return true, false, or null: if you return null, Laravel continues with the matching Gate or Policy:
Gate::before(function (User $user, string $ability) {
return $user->isAdmin() ? true : null;
});Gate::after works the other way around: it runs after the main check and can override the result. It is the canonical mechanism for the "admin can do everything" pattern without touching each closure.
Policies: Model-Based Authorization
Creating a Policy with php artisan make:policy
Policies are generated with artisan. The --model flag creates the class with the standard methods already sketched out and wires it by convention:
php artisan make:policy PostPolicy --model=PostThe command creates app/Policies/PostPolicy.php with methods like viewAny, view, create, update, delete, restore, and forceDelete. By convention, the first argument of each method is the authenticated user and the second is the model instance; in create there is no model, so it only receives the user.
Auto-Discovery by Convention and Explicit Registration
Laravel auto-discovers Policies as long as the model lives in App\Models and the policy lives in App\Policies named after the model plus the Policy suffix. If your models live in another namespace, register the association manually in AppServiceProvider with Gate::policy(Post::class, PostPolicy::class). Auto-discovery also works in Laravel 13, so in most projects you don't need a single line of registration.
The Standard Methods: viewAny, view, create, update, delete
Each method decides on one action of the resource. A realistic example for a blog:
class PostPolicy
{
public function viewAny(?User $user): bool
{
return true; // the post list is public
}
public function view(?User $user, Post $post): bool
{
return true; // reading a post needs no permission
}
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;
}
}Notice the ?User type on the public methods: guests without a session can read content, but any action that requires an identity receives a non-nullable User and Laravel denies guests automatically.
Protecting Controllers and Routes
$this->authorize() and authorizeResource() for the Whole CRUD
Inside a controller that uses the AuthorizesRequests trait (the one Laravel's base controllers ship with), $this->authorize() checks the matching ability and throws the 403 when it fails. To protect an entire CRUD at once, authorizeResource() maps each controller method to its policy ability:
use App\Models\Post;
use App\Policies\PostPolicy;
class PostController extends Controller
{
public function __construct()
{
$this->authorizeResource(Post::class, 'post');
}
public function update(Request $request, Post $post)
{
// authorizeResource already ran PostPolicy::update
$post->update($request->validated());
return redirect()->route('posts.show', $post);
}
}The second argument, 'post', is the name of the route parameter that holds the model. If you name it differently in your routes, adjust it so Laravel can resolve the instance.
The can:update,post Middleware on Routes
You can also protect at the route level with the can middleware. The first segment is the ability and the second is the route parameter holding the model:
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware('can:update,post');That way the route returns 403 before the controller is even reached, keeping your routes readable as documentation of the permissions. It is the natural choice when you want to protect standalone routes without a full CRUD behind them.
Custom Responses with Gate::inspect and Error Messages
When the generic 403 is not enough, Gate::inspect() returns a response with a custom message. The typical pattern: check with allows(), and if it fails, use inspect() to learn the exact reason and show it to the user instead of a cold error page:
$response = Gate::inspect('update', $post);
if ($response->allowed()) {
// continue
} else {
return back()->with('error', $response->message());
}You can customize the message by returning Gate::deny('Your message') from the closure or the policy method.
Authorization in Blade
@can, @cannot, and @canany in Views
In views, Blade directives keep you from repeating the check in every template. @can and @cannot take the ability and the model; @canany accepts a list and renders the block if the user can do any of them:
@can('update', $post)
<a href="{{ route('posts.edit', $post) }}">Edit</a>
@endcan
@cannot('delete', $post)
<p>Only the author can delete this post.</p>
@endcannotThese directives run exactly the same mechanism as authorize() and the middleware, so what you see in the view always matches what the server validates.
@auth and @guest: When to Use Them (and When Not)
@auth and @guest only check whether a session exists, not whether the user has permission. They are useful for deciding between "Sign in" and "My dashboard", but they should never replace @can for protecting concrete actions: a logged-in user without permissions would still see the button. The practical rule: sessions for identity, @can for authorization.
Complete Example: a Blog with Authors, Editors, and an Admin
Post and Comment Policies with the Author as Owner
Let's tie it all together in the running example: a Laravel 13 blog where each author edits and deletes only their own posts. The Post policy is already shown above; the Comment policy adds moderation, so an author can delete comments on their own posts even if they didn't write them:
class CommentPolicy
{
public function delete(User $user, Comment $comment): bool
{
// the comment author or the post author can delete it
return $user->id === $comment->user_id
|| $user->id === $comment->post->user_id;
}
}This is where you notice the difference from the scattered-if approach: the rule lives in a single place and applies identically from the controller, the route, and the view.
The isAdmin Gate with Gate::before for the Admin Role
The admin can do everything without duplicating conditions in each policy. With the Gate::before shown earlier, any ability returns true for them and the rest of the rules are skipped; other users keep evaluating their policies normally:
Gate::before(function (User $user, string $ability) {
return $user->hasRole('admin') ? true : null;
});Protecting the Dashboard and Moderation Routes
The admin dashboard is protected with a global Gate, not a policy, because it doesn't depend on a model: Gate::define('access-dashboard', fn (User $user) => $user->hasAnyRole(['admin', 'editor'])) and then the middleware on the dashboard route group. Moderation routes use can:delete,comment so only users who pass the Comment policy can delete.
Best Practices and Common Mistakes
Keep Business Logic Out of the Closures
Gates should delegate to the model or a service, not contain business logic. If the closure needs to check subscriptions, states, or dates, that logic belongs in the model or a service and the closure just queries it. That keeps rules independently testable and prevents duplication between Gates, Policies, and validators.
Authorization Tests with actingAs and Pest
Authorization is tested with actingAs(), which authenticates a user for the request. With Pest and response asserts, the test stays short and readable:
it('denies editing another user\'s post', function () {
$author = User::factory()->create();
$other = User::factory()->create();
$post = Post::factory()->for($author)->create();
$this->actingAs($other)
->put("/posts/{$post->id}", ['title' => 'Hack'])
->assertForbidden();
});
it('allows the admin to edit any post', function () {
$admin = User::factory()->admin()->create();
$post = Post::factory()->create();
$this->actingAs($admin)
->put("/posts/{$post->id}", ['title' => 'Edited'])
->assertOk();
});If you came from the blog's Pest 3 post, you'll see it matches the same style: describe the behavior, not the implementation.
Conclusion
Authorization in Laravel 13 comes down to two tools that complement each other: Gates for one-off and global permissions, Policies for per-resource control. Start by defining the policies of your models with make:policy, protect your controllers with authorizeResource(), add the admin Gate::before, and mirror the permissions in Blade with @can. Your code will stop asking who the user is and focus on what really matters: what they can do. Keep reading the blog for more practical Laravel 13 guides.


