• Fri, Mar 2026

Laravel Policies and Gates: Powerful Role-Based Access Control

Laravel Policies and Gates: Powerful Role-Based Access Control

This in-depth guide explores Laravel’s Policies and Gates, giving you the step-by-step skills to implement role-based access control (RBAC) in your applications. We’ll dive into practical coding examples, explain best practices, and show how to keep your Laravel applications secure. This tutorial is written like a professional teaching script—ideal for reading or recording as a video walkthrough.

Introduction

Authorization is one of the most critical aspects of any web application. Laravel provides two main approaches for handling authorization: Gates and Policies. While both serve the same purpose—defining who can do what—each shines in different scenarios.

In this tutorial, we’ll cover:

  • What Gates and Policies are
  • How to implement simple role checks
  • When to use a Gate vs. a Policy
  • Building role-based access control step by step
  • Practical examples you can adapt to your projects

Understanding Authorization in Laravel

Laravel separates authentication (who the user is) from authorization (what the user can do). Once a user logs in, you need to control access to resources based on roles or permissions. That’s where Gates and Policies come in.

Gates vs. Policies

Here’s a simple comparison:

FeatureGatesPolicies
DefinitionClosures that determine if a user is authorizedClasses dedicated to authorization logic for a model
Use caseSimple, one-off checksComplex, model-related permissions
OrganizationDefined in AuthServiceProviderSeparate class files inside app/Policies

Setting Up Roles and Permissions

Before we dive deep into Gates and Policies, let’s define a simple role system. For this tutorial, assume each user has a column role in the database.

// Migration snippet for users table
$table->string('role')->default('user');    

Common roles can be:

  • admin - Full access
  • editor - Limited access to content
  • user - Regular access

Working with Gates

Let’s start with Gates, the simplest way to define authorization rules.

Step 1: Defining a Gate

// app/Providers/AuthServiceProvider.php
use Illuminate\Support\Facades\Gate;
public function boot()
{
    Gate::define('edit-posts', function ($user) {
        return $user->role === 'editor' || $user->role === 'admin';
    });
}    

Step 2: Using a Gate in Controllers

public function update(Post $post)
{
    if (Gate::denies('edit-posts')) {
        abort(403, 'You are not allowed to edit posts');
    }
    // Update logic here
}    

Step 3: Using Gates in Blade

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

Working with Policies

When authorization rules become more complex, it’s better to use Policies.

Step 1: Generate a Policy

php artisan make:policy PostPolicy --model=Post    

Step 2: Define Policy Methods

// app/Policies/PostPolicy.php
public function update(User $user, Post $post)
{
    return $user->id === $post->user_id || $user->role === 'admin';
}
public function delete(User $user, Post $post)
{
    return $user->role === 'admin';
}    

Step 3: Register Policy

// app/Providers/AuthServiceProvider.php
protected $policies = [
    Post::class => PostPolicy::class,
];    

Step 4: Use Policy in Controllers

public function update(Post $post)
{
    $this->authorize('update', $post);
    // Update logic here
}    

Step 5: Use Policy in Blade

@can('delete', $post)
    <button>Delete</button>
@endcan    

Practical RBAC Implementation

Let’s implement a more structured role-based access control system.

Middleware for Roles

// app/Http/Middleware/RoleMiddleware.php
public function handle($request, Closure $next, ...$roles)
{
    if (! in_array($request->user()->role, $roles)) {
        abort(403, 'Unauthorized.');
    }
    return $next($request);
}    

Register Middleware

// app/Http/Kernel.php
'role' => \App\Http\Middleware\RoleMiddleware::class,    

Use in Routes

Route::get('/admin', function () {
    return 'Welcome Admin';
})->middleware('role:admin');    

Where Gates and Policies Fit Together

You don’t have to choose one exclusively. In real applications:

  • Use Gates for quick checks.
  • Use Policies for model-level authorization.
  • Use Middleware for route-level role checks.

Best Practices

  • Keep authorization logic out of controllers.
  • Use policies for anything tied to a model.
  • Organize roles and permissions in a database for flexibility.
  • Combine Gates and Policies strategically.

Full Practical Example: Laravel Policies & Gates (RBAC)

Prerequisites

  • Laravel 10 or 11 project installed and database configured in .env
  • Authentication scaffold (e.g., Breeze/Jetstream) recommended so you can log in. If you don’t have one, you can still use tinker to log in a seeded user.
# optional but recommended for quick login/register
composer require laravel/breeze --dev
php artisan breeze:install
php artisan migrate
npm install && npm run dev

1) Database Migrations

1.1 Add a role column to users

Create a migration to add a role to the users table.

php artisan make:migration add_role_to_users_table --table=users

Open the generated file in database/migrations/*_add_role_to_users_table.php and paste:

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('role')->default('user')->after('email');
        });
    }
    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('role');
        });
    }
};

1.2 Create the posts table

php artisan make:migration create_posts_table

Edit database/migrations/*_create_posts_table.php :

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->text('body');
            $table->boolean('is_published')->default(false);
            $table->timestamps();
            $table->softDeletes();
        });
    }
    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

Run migrations:

php artisan migrate

2) Models

2.1 Post model

php artisan make:model Post

Edit app/Models/Post.php :

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
    use HasFactory, SoftDeletes;
    protected $fillable = ['title', 'body', 'is_published'];
    public function user()
    {
        return $this->belongsTo(User::class);
    }
    public function scopePublished($query)
    {
        return $query->where('is_published', true);
    }
}

2.2 Update User model (optional helpers)

In app/Models/User.php add a simple role helper and relationship:

public function posts()
{
    return $this->hasMany(\App\Models\Post::class);
}
public function isRole(string $role): bool
{
    return $this->role === $role;
}

3) Factories & Seeders

3.1 PostFactory

php artisan make:factory PostFactory --model=Post

Edit database/factories/PostFactory.php :

<?php
namespace Database\Factories;
use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class PostFactory extends Factory
{
    protected $model = Post::class;
    public function definition(): array
    {
        return [
            'user_id'      => User::factory(),
            'title'        => $this->faker->sentence(6),
            'body'         => $this->faker->paragraphs(4, true),
            'is_published' => $this->faker->boolean(70),
        ];
    }
}

3.2 UserFactory (ensure a default role)

Open database/factories/UserFactory.php and ensure it sets a role:

public function definition(): array
{
    return [
        'name' => fake()->name(),
        'email' => fake()->unique()->safeEmail(),
        'email_verified_at' => now(),
        'password' => bcrypt('password'), // demo only
        'role' => 'user',
        'remember_token' => \Str::random(10),
    ];
}

3.3 UserSeeder

php artisan make:seeder UserSeeder

Edit database/seeders/UserSeeder.php :

<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
class UserSeeder extends Seeder
{
    public function run(): void
    {
        // Admin
        User::factory()->create([
            'name' => 'Admin One',
            'email' => 'admin@example.com',
            'role' => 'admin',
            'password' => bcrypt('password'),
        ]);
        // Editor
        User::factory()->create([
            'name' => 'Editor One',
            'email' => 'editor@example.com',
            'role' => 'editor',
            'password' => bcrypt('password'),
        ]);
        // Regular users
        User::factory(5)->create(['role' => 'user']);
    }
}

3.4 PostSeeder

php artisan make:seeder PostSeeder

Edit database/seeders/PostSeeder.php :

<?php
namespace Database\Seeders;
use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Seeder;
class PostSeeder extends Seeder
{
    public function run(): void
    {
        // Some posts for admin & editor
        $admin = User::where('email', 'admin@example.com')->first();
        $editor = User::where('email', 'editor@example.com')->first();
        Post::factory(5)->create(['user_id' => $admin->id]);
        Post::factory(5)->create(['user_id' => $editor->id]);
        // And posts for random users too
        User::where('role', 'user')->get()->each(function ($user) {
            Post::factory(2)->create(['user_id' => $user->id]);
        });
    }
}

3.5 DatabaseSeeder

Wire seeders in database/seeders/DatabaseSeeder.php :

public function run(): void
{
    $this->call([
        UserSeeder::class,
        PostSeeder::class,
    ]);
}

Seed your database:

php artisan db:seed

4) Gates

Define a few app-wide checks in app/Providers/AuthServiceProvider.php :

<?php
namespace App\Providers;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
    protected $policies = [
        // We'll map Post :: PostPolicy below in the Policies section
    ];
    public function boot(): void
    {
        // Generic admin gate
        Gate::define('access-admin', function (User $user) {
            return $user->role === 'admin';
        });
        // Editors and admins can manage posts
        Gate::define('manage-posts', function (User $user) {
            return in_array($user->role, ['admin', 'editor']);
        });
        // View unpublished posts (admin or the owner)
        Gate::define('view-unpublished', function (User $user, Post $post) {
            return $user->role === 'admin' || $user->id === $post->user_id;
        });
    }
}

Usage quickies:Gate::allows('manage-posts'), @can('manage-posts'), @cannot('access-admin')

5) Policy

Create a policy for the Post model:

php artisan make:policy PostPolicy --model=Post

Edit app/Policies/PostPolicy.php :

<?php
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
class PostPolicy
{
    public function viewAny(User $user): bool
    {
        return true; // all authenticated users can view list
    }
    public function view(User $user, Post $post): bool
    {
        // published posts are public; drafts only for owner/admin/editor
        return $post->is_published
            || $user->role === 'admin'
            || $user->role === 'editor'
            || $user->id === $post->user_id;
    }
    public function create(User $user): bool
    {
        // only admin/editor create posts
        return in_array($user->role, ['admin', 'editor']);
    }
    public function update(User $user, Post $post): bool
    {
        // owner can edit; admin/editor can edit any
        return $user->id === $post->user_id || in_array($user->role, ['admin', 'editor']);
    }
    public function delete(User $user, Post $post): bool
    {
        // only admin or owner
        return $user->id === $post->user_id || $user->role === 'admin';
    }
    public function restore(User $user, Post $post): bool
    {
        return $user->role === 'admin';
    }
    public function forceDelete(User $user, Post $post): bool
    {
        return $user->role === 'admin';
    }
}

Register the Policy

Update app/Providers/AuthServiceProvider.php to map the policy:

use App\Models\Post;
use App\Policies\PostPolicy;
protected $policies = [
    Post::class => PostPolicy::class,
];

6) Role Middleware (Optional but Helpful)

Create a lightweight middleware to guard entire routes by role.

php artisan make:middleware RoleMiddleware

Edit app/Http/Middleware/RoleMiddleware.php :

<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class RoleMiddleware
{
    public function handle(Request $request, Closure $next, ...$roles)
    {
        $user = $request->user();
        if (! $user || ! in_array($user->role, $roles)) {
            abort(403, 'Unauthorized.');
        }
        return $next($request);
    }
}

Register it in app/Http/Kernel.php :

protected $routeMiddleware = [
    // ...
    'role' => \App\Http\Middleware\RoleMiddleware::class,
];

7) Controller

Create a resource controller for posts.

php artisan make:controller PostController --resource --model=Post

Edit app/Http/Controllers/PostController.php :

<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
    public function __construct()
    {
        // Auto-authorize resource methods via PostPolicy
        $this->authorizeResource(Post::class, 'post');
    }
    public function index()
    {
        // everyone logged in can view list; show published + own drafts
        $posts = Post::query()
            ->with('user')
            ->when(auth()->user()->role !== 'admin' && auth()->user()->role !== 'editor', function ($q) {
                $q->where(function ($q) {
                    $q->where('is_published', true)
                      ->orWhere('user_id', auth()->id());
                });
            })
            ->latest()
            ->paginate(10);
        return view('posts.index', compact('posts'));
    }
    public function create()
    {
        return view('posts.create');
    }
    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string'],
            'is_published' => ['nullable', 'boolean'],
        ]);
        $post = auth()->user()->posts()->create([
            'title' => $validated['title'],
            'body' => $validated['body'],
            'is_published' => (bool) ($validated['is_published'] ?? false),
        ]);
        return redirect()->route('posts.show', $post)->with('status', 'Post created!');
    }
    public function show(Post $post)
    {
        // extra check: prevent non-authorized from viewing unpublished
        if (! $post->is_published && auth()->user()->cannot('view', $post)) {
            abort(403);
        }
        return view('posts.show', compact('post'));
    }
    public function edit(Post $post)
    {
        return view('posts.edit', compact('post'));
    }
    public function update(Request $request, Post $post)
    {
        $validated = $request->validate([
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string'],
            'is_published' => ['nullable', 'boolean'],
        ]);
        $post->update([
            'title' => $validated['title'],
            'body' => $validated['body'],
            'is_published' => (bool) ($validated['is_published'] ?? false),
        ]);
        return redirect()->route('posts.show', $post)->with('status', 'Post updated!');
    }
    public function destroy(Post $post)
    {
        $post->delete();
        return redirect()->route('posts.index')->with('status', 'Post deleted.');
    }
}

8) Routes

Wire the routes in routes/web.php :

<?php
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
    return redirect()->route('posts.index');
});
Route::middleware(['auth'])->group(function () {
    // Example admin-only screen guarded by role middleware + gate
    Route::get('/admin', function () {
        abort_unless(\Gate::allows('access-admin'), 403);
        return view('admin.dashboard'); // create file if you want
    })->middleware('role:admin')->name('admin.dashboard');
    Route::resource('posts', PostController::class);
});

9) Blade Views

Minimal Tailwind-free HTML to keep things portable. Add your CSS framework as needed.

9.1 Layout

resources/views/layouts/app.blade.php

<!DOCTYPE html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>RBAC Demo</title> 
</head>
<body>
  <nav>
    <a href="{{ route('posts.index') }}">Posts</a>
    @can('manage-posts')
      <a href="{{ route('posts.create') }}">New Post</a>
    @endcan
    @can('access-admin')
      <span>Admin</span>
      <a href="{{ route('admin.dashboard') }}">Admin Dashboard</a>
    @endcan
    <span >Logged in as: {{ auth()->user()->name }} ({{ auth()->user()->role }})</span>
  </nav>
  @if (session('status'))
    <div>{{ session('status') }}</div>
  @endif
  @yield('content')
</body>
</html>

9.2 Index

resources/views/posts/index.blade.php

@extends('layouts.app')
@section('content')
<h1>All Posts</h1>
@foreach($posts as $post)
  <div>
    <h2>{{ $post->title }}</h2>
    <p>{{ $post->content }}</p>
    <small>By: {{ $post->user->name }}</small>
    @can('update', $post)
      <a href="{{ route('posts.edit', $post) }}">Edit</a>
    @endcan
    @can('delete', $post)
      <form action="{{ route('posts.destroy', $post) }}" method="POST">
        @csrf
        @method('DELETE')
        <button type="submit">Delete</button>
      </form>
    @endcan
  </div>
@endforeach
@endsection

9.3 Create

resources/views/posts/create.blade.php

@extends('layouts.app')
@section('content')
<h1>Create Post</h1>
<form method="POST" action="{{ route('posts.store') }}">
  @csrf
  <input type="text" name="title" placeholder="Title" required>
  <textarea name="content" placeholder="Content" required></textarea>
  <button type="submit">Create</button>
</form>
@endsection

9.4 Edit

resources/views/posts/edit.blade.php

@extends('layouts.app')
@section('content')
<h1>Edit Post</h1>
<form method="POST" action="{{ route('posts.update', $post) }}">
  @csrf
  @method('PUT')
  <input type="text" name="title" value="{{ $post->title }}" required>
  <textarea name="content" required>{{ $post->content }}</textarea>
  <button type="submit">Update</button>
</form>
@endsection

9.5 Show

resources/views/posts/show.blade.php

@extends('layouts.app')
@section('content')
 <h1>{{ $post->title }}</h1>
 <p >by {{ $post->user->name }} • {{ $post->created_at->toDayDateTimeString() }}</p>
 @unless($post->is_published)
  <p>Draft (visible only to owner / editor / admin)</p>
 @endunless
 <article>{{ $post->body }}</article>
 <div >
  @can('update', $post)
   <a href="{{ route('posts.edit', $post) }}">Edit</a>
  @endcan
  @can('delete', $post)
   <form method="POST" action="{{ route('posts.destroy', $post) }}" onsubmit="return confirm('Delete this post?')">
    @csrf
    @method('DELETE')
    <button type="submit">Delete</button>
   </form>
  @endcan
  <a href="{{ route('posts.index') }}">Back</a>
 </div>
@endsection

10) Testing the Flow

  1. Run php artisan migrate --seed.
  2. Log in as:
    • admin@example.com / password
    • editor@example.com / password
  3. Visit /posts. Admin & Editor can create posts. Regular users can view published posts and their own drafts, but cannot create new ones.
  4. Admin Dashboard: /admin — requires role admin and passes gate access-admin.

11) Security & Best Practices Checklist

  • Keep business logic in Policies, not controllers or views.
  • Favor $this->authorize() / $this->authorizeResource() in controllers to enforce rules automatically.
  • Use Gates for quick, cross-cutting checks; Policies for model-centric permissions.
  • Hide actions in Blade with @can / @cannot but also enforce on the server side (never rely on UI only).
  • Consider a DB-backed permissions system for large apps (roles ↔ permissions tables).

12) (Optional) Permissions Table Design

If you want to evolve from simple roles to granular permissions, here’s a sample schema to inspire your design.

Conceptual HTML Table (for documentation)
TableKey ColumnsDescription
rolesid, name (admin, editor, user)Defines user roles
permissionsid, name (post.create, post.update, post.delete)Grants a specific capability
role_permissionrole_id, permission_idPivots permissions onto roles
user_roleuser_id, role_idAssigns multiple roles to users if needed

13) Quick Reference (Copy Sheet)

# Migrations
php artisan make:migration add_role_to_users_table --table=users
php artisan make:migration create_posts_table
# Models
php artisan make:model Post
# Policy
php artisan make:policy PostPolicy --model=Post
# Controller & Resource
php artisan make:controller PostController --resource --model=Post
# Middleware
php artisan make:middleware RoleMiddleware
# Seed
php artisan make:seeder UserSeeder
php artisan make:seeder PostSeeder
php artisan db:seed
# Run
php artisan migrate --seed
php artisan serve

That’s it! You now have a working RBAC demo featuring roles, Gates, Policies, and a posts module with full CRUD, controllers, routes, and Blade views. Drop this into a fresh Laravel project, run the migrations and seeders, and start experimenting.

Conclusion

By now, you should be confident using Laravel’s authorization tools. Gates are perfect for small checks, while Policies organize your model-related permissions. Together, they form a flexible RBAC system that keeps your app secure and maintainable.

This website uses cookies to enhance your browsing experience. By continuing to use this site, you consent to the use of cookies. Please review our Privacy Policy for more information on how we handle your data. Cookie Policy