An Introduction to Laravel Authorization Gates

Published on by

An Introduction to Laravel Authorization Gates image

Laravel Gate has an elegant mechanism to ensure users are authorized to perform actions on resources.

Before version 5.1, developers used ACL packages such as Entrust or Sentinel along with middlewares for authorization. The problem with this approach is the permissions you attach to users are just flags; they don’t encode the complex logic of the permission for some use cases. We have to write the actual access logic within controllers.

Gate avoids some drawbacks of using just these mentioned packages:

Opinionated use case: Gate doesn’t define how you implement your models; it is up to you. This gives you the freedom to write all the complex specs your use case has however you like. You can even use ACL packages with Laravel Gate. Defining logic(policy): Using Gate we can decouple access logic from business logic, which helps remove the clutter from controllers.

A Usage Example

In this post, we’ll make a toy posts app to show how Gate gives you liberty and decoupling. The web app will have two user roles (authors and editors) with the following permissions:

  • Authors can create a post.
  • Authors can update their posts.
  • Editors can update any post.
  • Editors can publish posts.

Create a New Laravel Project

First, create a new Laravel 5.4 application.

laravel new blog

If you don’t have Laravel Installer, use composer create-project.

composer create-project --prefer-dist laravel/laravel blog

Basic Config

Update the .env file and give Laravel access to a database you’ve created.

...
APP_URL=http://localhost:8000
...
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=dbname
DB_USERNAME=dbuser
DB_PASSWORD=yoursecretdbuserpassword
...

Database

Now, let’s create a Post model. Using -m and -c arguments we can create a migration and a controller for posts.

php artisan make:model Post -m -c

Next, update posts migration and add the following fields.

Open the posts migration file and add the following to up method:

Schema::create('posts', function (Blueprint $table) {
$table->increments('id');
$table->string('title');
$table->string('slug')->unique();
$table->text('body');
$table->boolean('published')->default(false);
$table->unsignedInteger('user_id');
$table->timestamps();
 
$table->foreign('user_id')->references('id')->on('users');
});

We need to add a couple of other tables: roles and user_roles pivot table. We’re going to put the permissions inside the roles table like Sentinel does.

php artisan make:model Role -m
Schema::create('roles', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('slug')->unique();
$table->jsonb('permissions')->default('{}'); // jsonb deletes duplicates
$table->timestamps();
});

And, lastly, role_users pivot table.

php artisan make:migration create_role_users_table
Schema::create('role_users', function (Blueprint $table) {
$table->unsignedInteger('user_id');
$table->unsignedInteger('role_id');
$table->timestamps();
 
$table->unique(['user_id','role_id']);
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade');
});
 
...

Seeding the Database

To wrap up our database initialization, we’ll make seeds for roles.

php artisan make:seeder RolesSeeder
use Illuminate\Database\Seeder;
use App\Role;
 
class RolesSeeder extends Seeder
{
public function run()
{
$author = Role::create([
'name' => 'Author',
'slug' => 'author',
'permissions' => [
'create-post' => true,
]
]);
$editor = Role::create([
'name' => 'Editor',
'slug' => 'editor',
'permissions' => [
'update-post' => true,
'publish-post' => true,
]
]);
}
}

Don’t forget to call RolesSeeder from DatabaseSeeder.

$this->call(\RolesSeeder::class);

User and Role Models

If we execute the seed command, it will fail because we haven’t set our models yet. Let’s add fillable fields to app/Role model, and tell our model permissions is a JSON type field. We also need to create relationships between app/Role and app/User models.

class Role extends Model
{
protected $fillable = [
'name', 'slug', 'permissions',
];
protected $casts = [
'permissions' => 'array',
];
 
public function users()
{
return $this->belongsToMany(User::class, 'role_users');
}
 
public function hasAccess(array $permissions) : bool
{
foreach ($permissions as $permission) {
if ($this->hasPermission($permission))
return true;
}
return false;
}
 
private function hasPermission(string $permission) : bool
{
return $this->permissions[$permission] ?? false;
}
}
class User extends Authenticatable
{
use Notifiable;
 
protected $fillable = [
'name', 'email', 'password',
];
 
protected $hidden = [
'password', 'remember_token',
];
 
public function roles()
{
return $this->belongsToMany(Role::class, 'role_users');
}
 
/**
* Checks if User has access to $permissions.
*/
public function hasAccess(array $permissions) : bool
{
// check if the permission is available in any role
foreach ($this->roles as $role) {
if($role->hasAccess($permissions)) {
return true;
}
}
return false;
}
 
/**
* Checks if the user belongs to role.
*/
public function inRole(string $roleSlug)
{
return $this->roles()->where('slug', $roleSlug)->count() == 1;
}
}

Now we can safely migrate and seed our database.

php artisan migrate --seed

Auth

Laravel provides a quick way to create routes and views for a simple authentication system using the following command:

php artisan make:auth

It will make controllers, views, and routes for us but we need to modify the registration to add the user role.

Registration

Let’s make roles available to the register view first. In Controllers/Auth/RegisterController.php override the showRegistrationForm method.

Use App/Role;
 
...
 
public function showRegistrationForm()
{
$roles = Role::orderBy('name')->pluck('name', 'id');
return view('auth.register', compact('roles'));
}

Edit resources/views/auth/register.blade.php and add a select input.

<br></br>...
 
<div class="form-group{{ $errors->has('role') ? ' has-error' : '' }}">
<label for="role" class="col-md-4 control-label">User role</label>
 
<div class="col-md-6">
<select id="role" class="form-control" name="role" required>
@foreach($roles as $id => $role)
<option value="{{$id}}">{{$role}}</option>
@endforeach
</select>
 
@if ($errors->has('role'))
<span class="help-block">
<strong>{{ $errors->first('role') }}</strong>
</span>
@endif
</div>
</div>
 
...

Don’t forget to validate the new field we’ve added. Update validator method in RegisterController.

<br></br>...
 
protected function validator(array $data)
{
return Validator::make($data, [
'name' => 'required|max:255',
'email' => 'required|email|max:255|unique:users',
'password' => 'required|min:6|confirmed',
'role' => 'required|exists:roles,id', // validating role
]);
}
 
...

Override the create method in the controller (the method is inherited from RegistersUsers trait) and attach the role to the registered user.

<br></br>...
 
protected function create(array $data)
{
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
]);
$user->roles()->attach($data['role']);
return $user;
}
 
...

Change redirection link in RegisterController and in LoginController as well.

<br></br>...
 
protected $redirectTo = '/';
 
...

Run the Application

If you run a server using php artisan serve command you’ll be able to create a user and attach a role to it from the browser. Visit /register and create a new user.

Define policies

Here we will define access policies to protect our actions. Update app/Providers/AuthServiceProvider.php to include the app’s policies.

use App\Post;
 
...
 
public function boot()
{
$this->registerPolicies();
$this->registerPostPolicies();
}
 
public function registerPostPolicies()
{
Gate::define('create-post', function ($user) {
return $user->hasAccess(['create-post']);
});
Gate::define('update-post', function ($user, Post $post) {
return $user->hasAccess(['update-post']) or $user->id == $post->user_id;
});
Gate::define('publish-post', function ($user) {
return $user->hasAccess(['publish-post']);
});
Gate::define('see-all-drafts', function ($user) {
return $user->inRole('editor');
});
}

Routes

Let’s define our routes now; update routes/web.php with all our app’s routes.

Auth::routes();
 
Route::get('/', 'PostController@index');
Route::get('/posts', 'PostController@index')->name('list_posts');
Route::group(['prefix' => 'posts'], function () {
Route::get('/drafts', 'PostController@drafts')
->name('list_drafts')
->middleware('auth');
Route::get('/show/{id}', 'PostController@show')
->name('show_post');
Route::get('/create', 'PostController@create')
->name('create_post')
->middleware('can:create-post');
Route::post('/create', 'PostController@store')
->name('store_post')
->middleware('can:create-post');
Route::get('/edit/{post}', 'PostController@edit')
->name('edit_post')
->middleware('can:update-post,post');
Route::post('/edit/{post}', 'PostController@update')
->name('update_post')
->middleware('can:update-post,post');
// using get to simplify
Route::get('/publish/{post}', 'PostController@publish')
->name('publish_post')
->middleware('can:publish-post');
});

Posts

Let’s get busy with our posts, shall we?

Post model

First, we define our fillable fields, then add Eloquent relationships.

<br></br>...
 
class Post extends Model
{
protected $fillable = [
'title', 'slug', 'body', 'user_id',
];
 
public function owner()
{
return $this->belongsTo(User::class);
}
 
public function scopePublished($query)
{
return $query->where('published', true);
}
 
public function scopeUnpublished($query)
{
return $query->where('published', false);
}
}

Posts Controller

We’ve already created a controller, now let’s make it useful.

List posts

Add index method to list all published posts in PostController.php.

use App\Post;
 
...
 
public function index()
{
$posts = Post::published()->paginate();
return view('posts.index', compact('posts'));
}
 
...

Next, edit resources/views/home.blade.php and rename it to resources/views/posts/index.blade.php.

@extends('layouts.app')
 
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">
Posts
@can('create-post')
<a class="pull-right btn btn-sm btn-primary" href="{{ route('create_post') }}">New</a>
@endcan
</div>
 
<div class="panel-body">
<div class="row">
@foreach($posts as $post)
<div class="col-sm-6 col-md-4">
<div class="thumbnail">
<div class="caption">
<h3><a href="{{ route('edit_post', ['id' => $post->id]) }}">{{ $post->title }}</a></h3>
<p>{{ str_limit($post->body, 50) }}</p>
@can('update-post', $post)
<p>
<a href="{{ route('edit_post', ['id' => $post->id]) }}" class="btn btn-sm btn-default" role="button">Edit</a>
</p>
@endcan
</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

If we access the posts page as guests we won’t see the new button; only authors will be allowed to see it and access the page.

Create Posts

Let’s make a create post page. Add the following method to PostController.

<br></br>...
 
public function create()
{
return view('posts.create');
}
 
...

Create a view file and name it posts\create.blade.php.

@extends('layouts.app')
 
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">New Post</div>
 
<div class="panel-body">
<form class="form-horizontal" role="form" method="POST" action="{{ route('store_post') }}">
{{ csrf_field() }}
 
<div class="form-group{{ $errors->has('title') ? ' has-error' : '' }}">
<label for="title" class="col-md-4 control-label">Title</label>
 
<div class="col-md-6">
<input id="title" type="text" class="form-control" name="title" value="{{ old('title') }}" required autofocus>
 
@if ($errors->has('title'))
<span class="help-block">
<strong>{{ $errors->first('title') }}</strong>
</span>
@endif
</div>
</div>
 
<div class="form-group{{ $errors->has('body') ? ' has-error' : '' }}">
<label for="body" class="col-md-4 control-label">Body</label>
 
<div class="col-md-6">
<textarea name="body" id="body" cols="30" rows="10" class="form-control" required>{{ old('body') }}</textarea>
@if ($errors->has('body'))
<span class="help-block">
<strong>{{ $errors->first('body') }}</strong>
</span>
@endif
</div>
</div>
 
<div class="form-group">
<div class="col-md-6 col-md-offset-4">
<button type="submit" class="btn btn-primary">
Create
</button>
<a href="{{ route('list_posts') }}" class="btn btn-primary">
Cancel
</a>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection

Store Post

Next, we will make store method.

use App\Http\Requests\StorePost as StorePostRequest;
use Auth;
 
...
 
public function store(StorePostRequest $request)
{
$data = $request->only('title', 'body');
$data['slug'] = str_slug($data['title']);
$data['user_id'] = Auth::user()->id;
$post = Post::create($data);
return redirect()->route('edit_post', ['id' => $post->id]);
}
 
...

We need to make a StorePost request to validate the form data before storing posts. It’s simple; execute the following Artisan command.

php artisan make:request StorePost

Edit app/Http/Requests/StorePost.php and provide the validation we need in rules method. authorize method should always return true because we’re using Gate middlewares to do the actual access authorization.

public function authorize()
{
return true; // gate will be responsible for access
}
 
public function rules()
{
return [
'title' => 'required|unique:posts',
'body' => 'required',
];
}

Drafts

We only want authors to be able to create posts, but these won’t be accessible to the public until the editors publish them. Thus, we will make a page for drafts or unpublished posts which will be only accessible by authenticated users.

To show drafts, add the drafts method to PostController.

use Gate;
 
...
 
public function drafts()
{
$postsQuery = Post::unpublished();
if(Gate::denies('see-all-drafts')) {
$postsQuery = $postsQuery->where('user_id', Auth::user()->id);
}
$posts = $postsQuery->paginate();
return view('posts.drafts', compact('posts'));
}
 
...

Create the posts/drafts.blade.php view.

@extends('layouts.app')
 
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">
Drafts <a class="btn btn-sm btn-default pull-right" href="{{ route('list_posts') }}">Return</a>
</div>
 
<div class="panel-body">
<div class="row">
@foreach($posts as $post)
<div class="col-sm-6 col-md-4">
<div class="thumbnail">
<div class="caption">
<h3><a href="{{ route('show_post', ['id' => $post->id]) }}">{{ $post->title }}</a></h3>
<p>{{ str_limit($post->body, 50) }}</p>
<p>
@can('publish-post')
<a href="{{ route('publish_post', ['id' => $post->id]) }}" class="btn btn-sm btn-default" role="button">Publish</a>
@endcan
<a href="{{ route('edit_post', ['id' => $post->id]) }}" class="btn btn-default" role="button">Edit</a>
</p>
</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

We need to make a link to access the drafts page. In layouts/app.blade.php, modify the dropdown menu and add a link to drafts page.

<br></br>...
 
<ul class="dropdown-menu" role="menu">
<li>
<a href="{{ route('list_drafts') }}">Drafts</a>
 
...

Edit Posts

Let’s make it possible to edit drafts and published posts. Add the following methods to PostController.

use App\Http\Requests\UpdaPost as UpdatePostRequest;
 
...
 
public function edit(Post $post)
{
return view('posts.edit', compact('post'));
}
 
public function update(Post $post, UpdatePostRequest $request)
{
$data = $request->only('title', 'body');
$data['slug'] = str_slug($data['title']);
$post->fill($data)->save();
return back();
}

We also need to create a new FormRequest. We have to make post titles unique, but allow them to have the same title on update. We use Laravel Rule for this.

Note the Post model will be accessible from the Request object because we have bound the id from the route to Post model. Check the official docs for more details.

php artisan make:request UpdatePost
use Illuminate\Validation\Rule;
 
...
 
public function authorize()
{
return true;
}
 
public function rules()
{
$id = $this->route('post')->id;
return [
'title' => [
'required',
Rule::unique('posts')->where('id', '<>', $id),
],
'body' => 'required',
];
}

Create posts/edit.blade.php view

@extends('layouts.app')
 
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">Update Post</div>
 
<div class="panel-body">
<form class="form-horizontal" role="form" method="POST" action="{{ route('update_post', ['post' => $post->id]) }}">
{{ csrf_field() }}
 
<div class="form-group{{ $errors->has('title') ? ' has-error' : '' }}">
<label for="title" class="col-md-4 control-label">Title</label>
 
<div class="col-md-6">
<input id="title" type="text" class="form-control" name="title" value="{{ old('title', $post->title) }}" required autofocus>
 
@if ($errors->has('title'))
<span class="help-block">
<strong>{{ $errors->first('title') }}</strong>
</span>
@endif
</div>
</div>
 
<div class="form-group{{ $errors->has('body') ? ' has-error' : '' }}">
<label for="body" class="col-md-4 control-label">Body</label>
 
<div class="col-md-6">
<textarea name="body" id="body" cols="30" rows="10" class="form-control" required>{{ old('body', $post->body) }}</textarea>
@if ($errors->has('body'))
<span class="help-block">
<strong>{{ $errors->first('body') }}</strong>
</span>
@endif
</div>
</div>
 
<div class="form-group">
<div class="col-md-6 col-md-offset-4">
<button type="submit" class="btn btn-primary">
Update
</button>
@can('publish-post')
<a href="{{ route('publish_post', ['post' => $post->id]) }}" class="btn btn-primary">
Publish
</a>
@endcan
<a href="{{ route('list_posts') }}" class="btn btn-primary">
Cancel
</a>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection

Publish Drafts

For the sake of simplicity, we will publish posts by visiting a get entry point. Using post with a form would be best. Add publish to PostController.

<br></br>...
 
public function publish(Post $post)
{
$post->published = true;
$post->save();
return back();
}
 
...

Show Post

Let’s make posts available for publishing now. Add show to PostController.

<br></br>...
 
public function show($id)
{
$post = Post::published()->findOrFail($id);
return view('posts.show', compact('post'));
}

And the view, of course, is posts/show.blade.php.

@extends('layouts.app')
 
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">
{{ $post->title }}
<a class="btn btn-sm btn-default pull-right" href="{{ route('list_posts') }}">Return</a>
</div>
 
<div class="panel-body">
{{ $post->body }}
</div>
</div>
</div>
</div>
</div>
@endsection

404

To have a clean appearance when a user tries to visit a nonexisting page, we need to make a 404 page. We need to create a new Blade file errors/404.blade.php and allow the user to go back to a proper page.

<html>
<body>
<h1>404</h1>
<a href="/">Back</a>
</body>
</html>

Conclusion

Our dummy application allows every user type to perform exactly the actions he has permission to. We’ve achieved that easily without using any third party packages. Thanks to this approach made possible by new Laravel releases, we don’t have to inherit features we don’t need from packages.

The business logic is decoupled from the access logic in our application; this makes it easier to maintain. If our ACL specs change, we probably won’t have to touch the controllers at all.

Next time I’ll make a post about using Gate with third party ACL packages.

Yazid Hanifi photo

Developer, Machine Learning student, plays soccer regularly.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate logo

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate

Build your SaaS application in hours. Out-of-the-box multi-tenancy and seamless Stripe integration. Supports subscriptions and one-time purchases, allowing you to focus on building and creating without repetitive setup tasks.

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector
MongoDB logo

MongoDB

Enhance your PHP applications with the powerful integration of MongoDB and Laravel, empowering developers to build applications with ease and efficiency. Support transactional, search, analytics and mobile use cases while using the familiar Eloquent APIs. Discover how MongoDB's flexible, modern database can transform your Laravel applications.

MongoDB

The latest

View all →
PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell image

PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell

Read article
Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3 image

Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3

Read article
Register now for Sentry Launch Week! image

Register now for Sentry Launch Week!

Read article
Hide and safeguard emails from bots with the Muddle Laravel package image

Hide and safeguard emails from bots with the Muddle Laravel package

Read article
Dynamic Cache, Database, and Mail Builders in Laravel 11.31 image

Dynamic Cache, Database, and Mail Builders in Laravel 11.31

Read article
PHPStan 2.0 is Here image

PHPStan 2.0 is Here

Read article