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.
1laravel new blog
If you don’t have Laravel Installer, use composer create-project
.
1composer create-project --prefer-dist laravel/laravel blog
Basic Config
Update the .env
file and give Laravel access to a database you’ve created.
1... 2APP_URL=http://localhost:8000 3... 4DB_CONNECTION=mysql 5DB_HOST=127.0.0.1 6DB_PORT=3306 7DB_DATABASE=dbname 8DB_USERNAME=dbuser 9DB_PASSWORD=yoursecretdbuserpassword10...
Database
Now, let’s create a Post
model. Using -m
and -c
arguments we can create a migration and a controller for posts.
1php 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:
1Schema::create('posts', function (Blueprint $table) { 2 $table->increments('id'); 3 $table->string('title'); 4 $table->string('slug')->unique(); 5 $table->text('body'); 6 $table->boolean('published')->default(false); 7 $table->unsignedInteger('user_id'); 8 $table->timestamps(); 910 $table->foreign('user_id')->references('id')->on('users');11});
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.
1php artisan make:model Role -m
1Schema::create('roles', function (Blueprint $table) {2 $table->increments('id');3 $table->string('name');4 $table->string('slug')->unique();5 $table->jsonb('permissions')->default('{}'); // jsonb deletes duplicates6 $table->timestamps();7});
And, lastly, role_users
pivot table.
1php artisan make:migration create_role_users_table
1Schema::create('role_users', function (Blueprint $table) { 2 $table->unsignedInteger('user_id'); 3 $table->unsignedInteger('role_id'); 4 $table->timestamps(); 5 6 $table->unique(['user_id','role_id']); 7 $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); 8 $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade'); 9});1011...
Seeding the Database
To wrap up our database initialization, we’ll make seeds for roles.
1php artisan make:seeder RolesSeeder
1use Illuminate\Database\Seeder; 2use App\Role; 3 4class RolesSeeder extends Seeder 5{ 6 public function run() 7 { 8 $author = Role::create([ 9 'name' => 'Author',10 'slug' => 'author',11 'permissions' => [12 'create-post' => true,13 ]14 ]);15 $editor = Role::create([16 'name' => 'Editor',17 'slug' => 'editor',18 'permissions' => [19 'update-post' => true,20 'publish-post' => true,21 ]22 ]);23 }24}
Don’t forget to call RolesSeeder
from DatabaseSeeder
.
1$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.
1class Role extends Model 2{ 3 protected $fillable = [ 4 'name', 'slug', 'permissions', 5 ]; 6 protected $casts = [ 7 'permissions' => 'array', 8 ]; 910 public function users()11 {12 return $this->belongsToMany(User::class, 'role_users');13 }1415 public function hasAccess(array $permissions) : bool16 {17 foreach ($permissions as $permission) {18 if ($this->hasPermission($permission))19 return true;20 }21 return false;22 }2324 private function hasPermission(string $permission) : bool25 {26 return $this->permissions[$permission] ?? false;27 }28}
1class User extends Authenticatable 2{ 3 use Notifiable; 4 5 protected $fillable = [ 6 'name', 'email', 'password', 7 ]; 8 9 protected $hidden = [10 'password', 'remember_token',11 ];1213 public function roles()14 {15 return $this->belongsToMany(Role::class, 'role_users');16 }1718 /**19 * Checks if User has access to $permissions.20 */21 public function hasAccess(array $permissions) : bool22 {23 // check if the permission is available in any role24 foreach ($this->roles as $role) {25 if($role->hasAccess($permissions)) {26 return true;27 }28 }29 return false;30 }3132 /**33 * Checks if the user belongs to role.34 */35 public function inRole(string $roleSlug)36 {37 return $this->roles()->where('slug', $roleSlug)->count() == 1;38 }39}
Now we can safely migrate and seed our database.
1php artisan migrate --seed
Auth
Laravel provides a quick way to create routes and views for a simple authentication system using the following command:
1php 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.
1Use App/Role;23...45public function showRegistrationForm()6{7 $roles = Role::orderBy('name')->pluck('name', 'id');8 return view('auth.register', compact('roles'));9}
Edit resources/views/auth/register.blade.php
and add a select input.
1<br></br>... 2 3<div class="form-group{{ $errors->has('role') ? ' has-error' : '' }}"> 4 <label for="role" class="col-md-4 control-label">User role</label> 5 6 <div class="col-md-6"> 7 <select id="role" class="form-control" name="role" required> 8 @foreach($roles as $id => $role) 9 <option value="{{$id}}">{{$role}}</option>10 @endforeach11 </select>1213 @if ($errors->has('role'))14 <span class="help-block">15 <strong>{{ $errors->first('role') }}</strong>16 </span>17 @endif18 </div>19</div>2021...
Don’t forget to validate the new field we’ve added. Update validator
method in RegisterController
.
1<br></br>... 2 3protected function validator(array $data) 4{ 5 return Validator::make($data, [ 6 'name' => 'required|max:255', 7 'email' => 'required|email|max:255|unique:users', 8 'password' => 'required|min:6|confirmed', 9 'role' => 'required|exists:roles,id', // validating role10 ]);11}1213...
Override the create
method in the controller (the method is inherited from RegistersUsers
trait) and attach the role to the registered user.
1<br></br>... 2 3protected function create(array $data) 4{ 5 $user = User::create([ 6 'name' => $data['name'], 7 'email' => $data['email'], 8 'password' => bcrypt($data['password']), 9 ]);10 $user->roles()->attach($data['role']);11 return $user;12}1314...
Change redirection link in RegisterController
and in LoginController
as well.
1<br></br>...23protected $redirectTo = '/';45...
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.
1use App\Post; 2 3... 4 5public function boot() 6{ 7 $this->registerPolicies(); 8 $this->registerPostPolicies(); 9}1011public function registerPostPolicies()12{13 Gate::define('create-post', function ($user) {14 return $user->hasAccess(['create-post']);15 });16 Gate::define('update-post', function ($user, Post $post) {17 return $user->hasAccess(['update-post']) or $user->id == $post->user_id;18 });19 Gate::define('publish-post', function ($user) {20 return $user->hasAccess(['publish-post']);21 });22 Gate::define('see-all-drafts', function ($user) {23 return $user->inRole('editor');24 });25}
Routes
Let’s define our routes now; update routes/web.php
with all our app’s routes.
1Auth::routes(); 2 3Route::get('/', 'PostController@index'); 4Route::get('/posts', 'PostController@index')->name('list_posts'); 5Route::group(['prefix' => 'posts'], function () { 6 Route::get('/drafts', 'PostController@drafts') 7 ->name('list_drafts') 8 ->middleware('auth'); 9 Route::get('/show/{id}', 'PostController@show')10 ->name('show_post');11 Route::get('/create', 'PostController@create')12 ->name('create_post')13 ->middleware('can:create-post');14 Route::post('/create', 'PostController@store')15 ->name('store_post')16 ->middleware('can:create-post');17 Route::get('/edit/{post}', 'PostController@edit')18 ->name('edit_post')19 ->middleware('can:update-post,post');20 Route::post('/edit/{post}', 'PostController@update')21 ->name('update_post')22 ->middleware('can:update-post,post');23 // using get to simplify24 Route::get('/publish/{post}', 'PostController@publish')25 ->name('publish_post')26 ->middleware('can:publish-post');27});
Posts
Let’s get busy with our posts, shall we?
Post model
First, we define our fillable fields, then add Eloquent relationships.
1<br></br>... 2 3class Post extends Model 4{ 5 protected $fillable = [ 6 'title', 'slug', 'body', 'user_id', 7 ]; 8 9 public function owner()10 {11 return $this->belongsTo(User::class);12 }1314 public function scopePublished($query)15 {16 return $query->where('published', true);17 }1819 public function scopeUnpublished($query)20 {21 return $query->where('published', false);22 }23}
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
.
1use App\Post; 2 3... 4 5public function index() 6{ 7 $posts = Post::published()->paginate(); 8 return view('posts.index', compact('posts')); 9}1011...
Next, edit resources/views/home.blade.php
and rename it to resources/views/posts/index.blade.php
.
1@extends('layouts.app') 2 3@section('content') 4<div class="container"> 5 <div class="row"> 6 <div class="col-md-8 col-md-offset-2"> 7 <div class="panel panel-default"> 8 <div class="panel-heading"> 9 Posts10 @can('create-post')11 <a class="pull-right btn btn-sm btn-primary" href="{{ route('create_post') }}">New</a>12 @endcan13 </div>1415 <div class="panel-body">16 <div class="row">17 @foreach($posts as $post)18 <div class="col-sm-6 col-md-4">19 <div class="thumbnail">20 <div class="caption">21 <h3><a href="{{ route('edit_post', ['id' => $post->id]) }}">{{ $post->title }}</a></h3>22 <p>{{ str_limit($post->body, 50) }}</p>23 @can('update-post', $post)24 <p>25 <a href="{{ route('edit_post', ['id' => $post->id]) }}" class="btn btn-sm btn-default" role="button">Edit</a>26 </p>27 @endcan28 </div>29 </div>30 </div>31 @endforeach32 </div>33 </div>34 </div>35 </div>36 </div>37</div>38@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
.
1<br></br>...23public function create()4{5 return view('posts.create');6}78...
Create a view file and name it posts\create.blade.php
.
1@extends('layouts.app') 2 3@section('content') 4<div class="container"> 5 <div class="row"> 6 <div class="col-md-8 col-md-offset-2"> 7 <div class="panel panel-default"> 8 <div class="panel-heading">New Post</div> 910 <div class="panel-body">11 <form class="form-horizontal" role="form" method="POST" action="{{ route('store_post') }}">12 {{ csrf_field() }}1314 <div class="form-group{{ $errors->has('title') ? ' has-error' : '' }}">15 <label for="title" class="col-md-4 control-label">Title</label>1617 <div class="col-md-6">18 <input id="title" type="text" class="form-control" name="title" value="{{ old('title') }}" required autofocus>1920 @if ($errors->has('title'))21 <span class="help-block">22 <strong>{{ $errors->first('title') }}</strong>23 </span>24 @endif25 </div>26 </div>2728 <div class="form-group{{ $errors->has('body') ? ' has-error' : '' }}">29 <label for="body" class="col-md-4 control-label">Body</label>3031 <div class="col-md-6">32 <textarea name="body" id="body" cols="30" rows="10" class="form-control" required>{{ old('body') }}</textarea>33 @if ($errors->has('body'))34 <span class="help-block">35 <strong>{{ $errors->first('body') }}</strong>36 </span>37 @endif38 </div>39 </div>4041 <div class="form-group">42 <div class="col-md-6 col-md-offset-4">43 <button type="submit" class="btn btn-primary">44 Create45 </button>46 <a href="{{ route('list_posts') }}" class="btn btn-primary">47 Cancel48 </a>49 </div>50 </div>51 </form>52 </div>53 </div>54 </div>55 </div>56</div>57@endsection
Store Post
Next, we will make store
method.
1use App\Http\Requests\StorePost as StorePostRequest; 2use Auth; 3 4... 5 6public function store(StorePostRequest $request) 7{ 8 $data = $request->only('title', 'body'); 9 $data['slug'] = str_slug($data['title']);10 $data['user_id'] = Auth::user()->id;11 $post = Post::create($data);12 return redirect()->route('edit_post', ['id' => $post->id]);13}1415...
We need to make a StorePost
request to validate the form data before storing posts. It’s simple; execute the following Artisan command.
1php 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.
1public function authorize() 2{ 3 return true; // gate will be responsible for access 4} 5 6public function rules() 7{ 8 return [ 9 'title' => 'required|unique:posts',10 'body' => 'required',11 ];12}
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
.
1use Gate; 2 3... 4 5public function drafts() 6{ 7 $postsQuery = Post::unpublished(); 8 if(Gate::denies('see-all-drafts')) { 9 $postsQuery = $postsQuery->where('user_id', Auth::user()->id);10 }11 $posts = $postsQuery->paginate();12 return view('posts.drafts', compact('posts'));13}1415...
Create the posts/drafts.blade.php
view.
1@extends('layouts.app') 2 3@section('content') 4<div class="container"> 5 <div class="row"> 6 <div class="col-md-8 col-md-offset-2"> 7 <div class="panel panel-default"> 8 <div class="panel-heading"> 9 Drafts <a class="btn btn-sm btn-default pull-right" href="{{ route('list_posts') }}">Return</a>10 </div>1112 <div class="panel-body">13 <div class="row">14 @foreach($posts as $post)15 <div class="col-sm-6 col-md-4">16 <div class="thumbnail">17 <div class="caption">18 <h3><a href="{{ route('show_post', ['id' => $post->id]) }}">{{ $post->title }}</a></h3>19 <p>{{ str_limit($post->body, 50) }}</p>20 <p>21 @can('publish-post')22 <a href="{{ route('publish_post', ['id' => $post->id]) }}" class="btn btn-sm btn-default" role="button">Publish</a>23 @endcan24 <a href="{{ route('edit_post', ['id' => $post->id]) }}" class="btn btn-default" role="button">Edit</a>25 </p>26 </div>27 </div>28 </div>29 @endforeach30 </div>31 </div>32 </div>33 </div>34 </div>35</div>36@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.
1<br></br>...23<ul class="dropdown-menu" role="menu">4 <li>5 <a href="{{ route('list_drafts') }}">Drafts</a>67...
Edit Posts
Let’s make it possible to edit drafts and published posts. Add the following methods to PostController
.
1use App\Http\Requests\UpdaPost as UpdatePostRequest; 2 3... 4 5public function edit(Post $post) 6{ 7 return view('posts.edit', compact('post')); 8} 910public function update(Post $post, UpdatePostRequest $request)11{12 $data = $request->only('title', 'body');13 $data['slug'] = str_slug($data['title']);14 $post->fill($data)->save();15 return back();16}
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.
1php artisan make:request UpdatePost
1use Illuminate\Validation\Rule; 2 3... 4 5public function authorize() 6{ 7 return true; 8} 910public function rules()11{12 $id = $this->route('post')->id;13 return [14 'title' => [15 'required',16 Rule::unique('posts')->where('id', '<>', $id),17 ],18 'body' => 'required',19 ];20}
Create posts/edit.blade.php
view
1@extends('layouts.app') 2 3@section('content') 4<div class="container"> 5 <div class="row"> 6 <div class="col-md-8 col-md-offset-2"> 7 <div class="panel panel-default"> 8 <div class="panel-heading">Update Post</div> 910 <div class="panel-body">11 <form class="form-horizontal" role="form" method="POST" action="{{ route('update_post', ['post' => $post->id]) }}">12 {{ csrf_field() }}1314 <div class="form-group{{ $errors->has('title') ? ' has-error' : '' }}">15 <label for="title" class="col-md-4 control-label">Title</label>1617 <div class="col-md-6">18 <input id="title" type="text" class="form-control" name="title" value="{{ old('title', $post->title) }}" required autofocus>1920 @if ($errors->has('title'))21 <span class="help-block">22 <strong>{{ $errors->first('title') }}</strong>23 </span>24 @endif25 </div>26 </div>2728 <div class="form-group{{ $errors->has('body') ? ' has-error' : '' }}">29 <label for="body" class="col-md-4 control-label">Body</label>3031 <div class="col-md-6">32 <textarea name="body" id="body" cols="30" rows="10" class="form-control" required>{{ old('body', $post->body) }}</textarea>33 @if ($errors->has('body'))34 <span class="help-block">35 <strong>{{ $errors->first('body') }}</strong>36 </span>37 @endif38 </div>39 </div>4041 <div class="form-group">42 <div class="col-md-6 col-md-offset-4">43 <button type="submit" class="btn btn-primary">44 Update45 </button>46 @can('publish-post')47 <a href="{{ route('publish_post', ['post' => $post->id]) }}" class="btn btn-primary">48 Publish49 </a>50 @endcan51 <a href="{{ route('list_posts') }}" class="btn btn-primary">52 Cancel53 </a>54 </div>55 </div>56 </form>57 </div>58 </div>59 </div>60 </div>61</div>62@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
.
1<br></br>... 2 3public function publish(Post $post) 4{ 5 $post->published = true; 6 $post->save(); 7 return back(); 8} 910...
Show Post
Let’s make posts available for publishing now. Add show
to PostController
.
1<br></br>...23public function show($id)4{5 $post = Post::published()->findOrFail($id);6 return view('posts.show', compact('post'));7}
And the view, of course, is posts/show.blade.php
.
1@extends('layouts.app') 2 3@section('content') 4<div class="container"> 5 <div class="row"> 6 <div class="col-md-8 col-md-offset-2"> 7 <div class="panel panel-default"> 8 <div class="panel-heading"> 9 {{ $post->title }}10 <a class="btn btn-sm btn-default pull-right" href="{{ route('list_posts') }}">Return</a>11 </div>1213 <div class="panel-body">14 {{ $post->body }}15 </div>16 </div>17 </div>18 </div>19</div>20@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.
1<html>2 <body>3 <h1>404</h1>4 <a href="/">Back</a>5 </body>6</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.