21
Implementing RBAC in Laravel Tutorial
This article was published on my personal blog
Role-Based Access Control, or RBAC, is the ability to add roles or restrict actions for users. It can be done in a general, high-level way, for example, to disallow some users from login into the admin panel. It can also be done more specifically, for example, allowing users to view a post but not edit it.
In this tutorial, you'll learn how to implement RBAC in Laravel using Bouncer. Bouncer is a PHP package that lets you add roles and abilities to your Eloquent models.
You'll build an editor that lets the user create private posts with the ability to allow other users to view and edit their posts. You can find the code for this tutorial in this GitHub repository.
You need to download Composer to follow along in this tutorial.
In addition, this tutorial uses Laravel 8 with PHP 7.3. To run Laravel 8, you need your PHP version to be at least 7.3.
You can check your PHP version in the terminal:
php -v
NPM is also used in some parts of this tutorial, but it's not important for implementing RBAC. If you want to follow along with everything, make sure you have NPM installed. NPM is installed by installing Node.js.
The first step is to set up the Laravel project. In your terminal, run the following command:
composer create-project laravel/laravel laravel-rbac-tutorial
Once that is done, switch to the directory of the project:
cd laravel-rbac-tutorial
Then, you need to add your database configuration on .env
:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
Now migrate Laravel's default migrations to your database:
php artisan migrate
This will add Laravel's default tables as well as the users
table.
To implement authentication easily, you can use Laravel UI. Install it with this command:
composer require laravel/ui
Then, run the following command to add the UI for authentication:
php artisan ui bootstrap --auth
This will add the directory app/Http/Controllers/Auth
with the controllers needed to implement authentication. It will also add the necessary view files resources/views
to add pages like login and register.
Then, compile the CSS and JavaScript assets added by the previous command:
npm install && npm run dev
This command might end in an error. If so, run the dev
script again:
npm run dev
Finally, go to app/Providers/RouteServiceProvider.php
and change the value for the constant HOME
:
public const HOME = '/';
Now, run the server:
php artisan serve
Then, go to localhost:8000
. You'll see the login form.
This is added by Laravel UI. Since you don't have a user yet, click on Register in the navigation bar. You'll see then a registration form.
After you register as a user, you'll be logged in.
Now, you'll add posts that the user will be able to create.
Start by creating a migration:
php artisan make:migration create_posts_table
This will create a new migration file in database/migrations
with the file name's suffix create_posts_table
.
Open the migration file and replace its content with the following:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePostsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->longText('content');
$table->foreignId('user_id')->constrained()->cascadeOnUpdate()->cascadeOnDelete();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('posts');
}
}
Now, migrate the changes to create the posts
table:
php artisan migrate
Next, create the file app/Models/Post.php
with the following content:
<?php
/**
* Created by Reliese Model.
*/
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
/**
* Class Post
*
* @property int $id
* @property string $title
* @property string $content
* @property int $user_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @property User $user
*
* @package App\Models
*/
class Post extends Model
{
protected $table = 'posts';
protected $casts = [
'user_id' => 'int'
];
protected $fillable = [
'title',
'content',
'user_id'
];
public function user()
{
return $this->belongsTo(User::class);
}
}
You'll now add the page the user will use to create a new post or edit an old one.
In your terminal, run:
php artisan make:controller PostController
This will create a new controller in app/Http/Controllers/PostController.php
. Open it and add a constructor method:
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
This adds the auth
middleware to all methods in this controller. This means that the user must be logged in before accessing any of the routes that point at this controller.
Next, add the postForm
function that renders the post form view:
public function postForm ($id = null) {
/** @var User $user */
$user = Auth::user();
$post = null;
if ($id) {
/** @var Post $post */
$post = Post::query()->find($id);
if (!$post || $post->user->id !== $user->id) {
return response()->redirectTo('/');
}
}
return view('post-form', ['post' => $post]);
}
Notice that this receives an optional id
paramter, then retrieves the post based on that ID. It also validates that the post exists and belongs to the current logged-in user. This is because this method will handle the request for both creating a post and editing a post.
Then, create the view resources/views/post-form.blade.php
with the following content:
@extends('layouts.app')
@push('head_scripts')
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/trix.css">
@endpush
@section('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">{{ $post ? __('Edit Post') :__('New Post') }}</div>
<div class="card-body">
<form method="POST" action="#">
@csrf
@error('post')
<div class="alert alert-danger">{{ $message }}</div>
@enderror
<div class="form-group">
<label for="title">{{ __('Title') }}</label>
<input type="text" name="title" id="title" placeholder="Title" required
value="{{ $post ? $post->title : old('title') }}" class="form-control @error('title') is-invalid @enderror" />
@error('title')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label for="content">{{ __('Content') }}</label>
@error('content')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
<input id="content" type="hidden" name="content" value="{{ $post ? $post->content : old('content') }}">
<trix-editor input="content"></trix-editor>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">{{ __('Submit') }}</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/trix.js"></script>
@endsection
This shows a form with 2 inputs. A title text input, and a text editor for the content. The text editor is Trix, an easy-to-use open-source editor.
You still need to add the link to the page. So, in resources/views/layouts/app.blade.php
add the following menu item in the ul
under the comment <!-- Left Side Of Navbar -->
:
<li class="nav-item">
<a class="nav-link" href="{{ route('post.form') }}">{{ __('New Post') }}</a>
</li>
Finally, add the route to the page in routes/web.php
:
Route::get('/post/{id?}', [PostController::class, 'postForm'])->name('post.form');
If you go to the website now, you'll notice a new link in the navbar that says "New Post". If you click on it, you'll see the form you created for the posts.
Before you implement the save functionality for posts, it's time to use Bouncer to implement RBAC.
In your terminal, run the following to install Bouncer:
composer require silber/bouncer v1.0.0-rc.10
Then, in app/Models/User.php
make the following changes:
use Silber\Bouncer\Database\HasRolesAndAbilities;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable, HasRolesAndAbilities;
Next, run the following command to add the migrations that Bouncer needs:
php artisan vendor:publish --tag="bouncer.migrations"
Finally, migrate these changes:
php artisan migrate
Bouncer is now ready to use. You can now add the save post functionality.
In app/Http/Controllers/PostController.php
add the new savePost
method:
public function savePost (Request $request, $id = null) {
/** @var $user */
$user = Auth::user();
Validator::validate($request->all(), [
'title' => 'required|min:1',
'content' => 'required|min:1'
]);
//all valid, validate id if not null
/** @var Post $post */
if ($id) {
$post = Post::query()->find($id);
if (!$post) {
return back()->withErrors(['post' => __('Post does not exist')]);
}
} else {
$post = new Post();
}
//set data
$post->title = $request->get('title');
$post->content = $request->get('content');
if (!$post->user) {
$post->user()->associate($user);
}
$post->save();
if (!$id) {
Bouncer::allow($user)->toManage($post);
}
return response()->redirectToRoute('post.form', ['id' => $post->id]);
}
In this method, you first validate that both title
and content
were entered in the form. Then, if the optional parameter id
is passed to the method you validate if it exists and if it belongs to this user. This is similar to the postForm
method.
After validating everything, you set title
and content
. Then, if the post is new you set the current user as the owner of the post with $post->user()->associate($user);
.
The important bit is here:
if (!$id) {
Bouncer::allow($user)->toManage($post);
}
Using the Bouncer
facade, you can use functions like allow
, which takes a user model. Then, you can give different types of permissions to the user. By using toManage
, you give the user all sorts of management permissions over the $post
instance.
Now, add the route for this method in routes/web.php
:
Route::post('/post/{id?}', [PostController::class, 'savePost'])->name('post.save');
Finally, change the form action in resources/views/post-form.blade.php
:
<form method="POST" action="{{ route('post.save', ['id' => $post ? $post->id : null]) }}">
If you go now the New Post page and try adding a post by filling the title
and content
fields then clicking Submit, you'll be redirected back to the form with the content filled in which means the post has been added.
To make the posts added visible to the user, change the index
method in app/Http/Controller/HomeController.php
to the following:
/**
* Show the application dashboard.
*
* @return \Illuminate\Contracts\Support\Renderable
*/
public function index()
{
/** @var User $user */
$user = Auth::user();
//get all posts
$posts = Post::query()->where('user_id', $user->id)->get();
return view('home', ['posts' => $posts]);
}
This will retrieve the posts that are made by the currently logged-in user.
Next, change resources/views/home.blade.php
to the following:
@extends('layouts.app')
@section('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<h1>{{ __('My Posts') }}</h1>
@forelse ($posts as $post)
<div class="card">
<div class="card-header">{{ $post->title }}</div>
<div class="card-body">
{!! $post->content !!}
</div>
<div class="card-footer">
{{ __('By ' . $post->user->name) }} -
<a href="{{ route('post.form', ['id' => $post->id]) }}">{{ __('Edit') }}</a>
</div>
</div>
@empty
<div class="card">
<div class="card-body">
{{ __('You have no posts') }}
</div>
</div>
@endforelse
</div>
</div>
</div>
@endsection
This will show the list of posts that the user has added if there are any.
If you open the home page now, you should see the post you added earlier.
In this section, you will be adding the functionality to allow access to other users either to edit or view a post. This will show how RBAC implementation works and how you can restrict or give access to a certain model for users.
In resources/views/home.blade.php
change the element with class .card-footer
that holds the Edit link to the following:
<div class="card-footer">
{{ __('By ' . $post->user->name) }} -
<a href="{{ route('post.form', ['id' => $post->id]) }}">{{ __('Edit') }}</a> -
<a href="{{ route('post.access', ['id' => $post->id, 'type' => 'view']) }}">{{ __('Change view access...') }}</a> -
<a href="{{ route('post.access', ['id' => $post->id, 'type' => 'edit']) }}">{{ __('Change edit access...') }}</a>
</div>
This adds 2 links for a new page which is access form. This page allows the user to change view or edit access for other users to the post.
In app/Http/Controllers/PostController.php
add a new method accessForm
:
public function accessForm ($id, $type) {
/** @var App/Models/User $user */
$user = Auth::user();
/** @var Post $post */
$post = Post::query()->find($id);
if (!$post || $post->user->id !== $user->id) {
return response()->redirectTo('/');
}
//get all users
$users = User::query()->where('id', '!=', $user->id)->get();
return view('post-access', ['post' => $post, 'users' => $users, 'type' => $type]);
}
This route receives 2 parameters: id
which is the post ID, and type
which is the type of access. The type of access can be view or edit.
In this method you first validate the post and make sure it exists and it belongs to the current logged in user. Then, you retrieve all other users and send them to the view post-access
which we'll create now.
Create resources/views/post-access.blade.php
with the following content:
@extends('layouts.app')
@section('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<h1>{{ __("Change " . ucwords($type) . " Access to Post") }}</h1>
<div class="card">
<div class="card-body">
<form method="POST" action={{ route('post.access.save', ['id' => $post->id, 'type' => $type]) }}>
@csrf
@forelse ($users as $user)
<div class="form-group">
<label for="user_{{ $user->id }}">
<input type="checkbox" name="users[]" id="user_{{ $user->id }}" value="{{ $user->id }}"
class="form-control d-inline mr-3" @if ($user->can($type, $post)) checked @endif
style="width: fit-content; vertical-align: middle;" />
<span>{{ $user->name }}</span>
</label>
</div>
@empty
{{ __('There are no users') }}
@endforelse
<div class="form-group">
<button type="submit" class="btn btn-primary">{{ __('Save') }}</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection
This shows the list of users with checkboxes. Those who already have access will already by checked, others unchecked.
Now, you'll create the route that handles saving the access for the post. In app/Http/Controllers/PostController.php
add the new method saveAccess
:
public function saveAccess (Request $request, $id, $type) {
/** @var User $user */
$user = Auth::user();
/** @var Post $post */
$post = Post::query()->find($id);
if (!$post || $post->user->id !== $user->id) {
return response()->redirectTo('/');
}
$users = $request->get('users', []);
$disallowedUserNotIn = $users;
$disallowedUserNotIn[] = $user->id;
//disallow users not checked
$disallowedUsers = User::query()->whereNotIn('id', $disallowedUserNotIn)->get();
/** @var User $disallowedUser */
foreach ($disallowedUsers as $disallowedUser) {
$disallowedUser->disallow($type, $post);
}
//allow checked users
$allowedUsers = User::query()->whereIn('id', $users)->get();
/** @var User $allowedUser */
foreach($allowedUsers as $allowedUser) {
$allowedUser->allow($type, $post);
}
return back();
}
This route also receives 2 parameters id
and type
, same as accessForm
. You also validate the post by checking that it exists and that it belongs to the current user.
Then, you retrieve the checked users from the request
. There are 2 actions to do here: disallow unchecked users to perform the action type
on the post, and allow checked users to perform the action type
on the post.
So, you first retrieve the users that are not in the array $users
which holds the checked user IDs. Then, you loop over them to perform the following method on each of them:
$disallowedUser->disallow($type, $post);
When the trait HasRolesAndAbilities
is added to a model, which we did earlier to the model User
, a set of methods are added to that m0del. One of them is disallow
which disallows the user a certain ability and you can specify a model to be more specific about what that ability is disabled on.
So, here you are disallowing the user in the loop to perform the action $type
on the post $post
.
Next, you retrieve the users that are in the $users
array and that should be granted the ability to perform action $type
on them. You loop over them and perform the following method:
$allowedUser->allow($type, $post);
Similar to disallow
, allow
is another method that is added by the trait HasRolesAndAbilities
. It allows the user to have the ability $type
either in general or on a given model that is specified as a second parameter.
Here, you allow the user to perform the action $type
on the post $post
.
Now, add the new routes in routes/web.php
:
Route::get('/post/access/{id}/{type}', [PostController::class, 'accessForm'])->name('post.access');
Route::post('/post/access/{id}/{type}', [PostController::class, 'saveAccess'])->name('post.access.save');
Now, there are some changes left to do. First, you need to change the condition in postForm
which allows users who have the permission to edit or view the post to access the page:
if (!$post || ($post->user->id !== $user->id && !$user->can('edit', $post) && !$user->can('view', $post))) {
return response()->redirectTo('/');
}
You also need to allow users who have edit permission to edit the post in savePost
:
if (!$post || ($post->user->id !== $user->id && !$user->can('edit', $post))) {
return back()->withErrors(['post' => __('Post does not exist')]);
}
In app/Http/Controllers/HomeController.php
in the index
method change the method to also retrieve the posts that the user has edit or view permissions on:
/**
* Show the application dashboard.
*
* @return \Illuminate\Contracts\Support\Renderable
*/
public function index()
{
/** @var User $user */
$user = Auth::user();
//get all posts
$posts = Post::query()->where('user_id', $user->id);
//get posts that the user is allowed to view or edit
$postIds = [];
$abilities = $user->getAbilities();
/** @var \Silber\Bouncer\Database\Ability */
foreach ($abilities as $ability) {
$postIds[] = $ability->entity_id;
}
$posts = $posts->orWhereIn('id', $postIds)->get();
return view('home', ['posts' => $posts]);
}
This is done using the getAbilities
method that is added on the User
model like allow
and disallow
. Each ability holds the id of the model it represents under entity_id
. We use that to get the ID of posts that the user has view or edit permissions on and retrieve them to show them on the home page.
In resources/views/home.blade.php
change the element with class .card-footer
to the following:
<div class="card-footer">
{{ __('By ' . $post->user->name) }} -
<a href="{{ route('post.form', ['id' => $post->id]) }}">{{ __('View') }}</a>
@can('manage', $post)
- <a href="{{ route('post.access', ['id' => $post->id, 'type' => 'view']) }}">{{ __('Change view access...') }}</a> -
<a href="{{ route('post.access', ['id' => $post->id, 'type' => 'edit']) }}">{{ __('Change edit access...') }}</a>
@endcan
</div>
You now only show the change access links to the user that manages the post so that not everyone that has access to the post can make changes to its access settings. This can be done by using the @can
blade directive which accepts the ability name and optionally a model instance. In this case, we check if the current user can manage
the post $post
.
Finally, you need to make changes to resources/views/post-form.blade.php
to ensure that if the user has view permission only they can't make edits on the post. This means that the form will become read-only.
Change the content of the element with the class card
to the following:
<div class="card-header">{{ $post ? __('View Post') :__('New Post') }}</div>
<div class="card-body">
<form method="POST" action="{{ route('post.save', ['id' => $post ? $post->id : null]) }}">
@csrf
@error('post')
<div class="alert alert-danger">{{ $message }}</div>
@enderror
@if ($post && !Auth::user()->can('edit', $post))
<div class="alert alert-info">{{ __('You have view permissions only') }}</div>
@endif
<div class="form-group">
<label for="title">{{ __('Title') }}</label>
<input type="text" name="title" id="title" placeholder="Title" required
value="{{ $post ? $post->title : old('title') }}" class="form-control @error('title') is-invalid @enderror"
@if($post && !Auth::user()->can('edit', $post)) disabled="true" @endif />
@error('title')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label for="content">{{ __('Content') }}</label>
@error('content')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
@if($post && !Auth::user()->can('edit', $post))
{!! $post->content !!}
@else
<input id="content" type="hidden" name="content" value="{{ $post ? $post->content : old('content') }}">
<trix-editor input="content"></trix-editor>
@endif
</div>
@if(!$post || Auth::user()->can('edit', $post))
<div class="form-group">
<button type="submit" class="btn btn-primary">{{ __('Submit') }}</button>
</div>
@endif
</form>
</div>
This makes the title
input disabled, shows the content of the post readable rather than in an editor, and hides the submit button when the user does not have edit
permissions.
Let's test all of this together. You first need to create another user to test this out so go ahead and register as a new user. Then log in again with the user you previously created and click on "Change view access..." on one of the posts you created. You'll see a form with the user you created and a checkbox.
Check the checkbox and click save. This will give the new user the ability to view the post.
Now, log out and log in again with the new user. You'll see the post in the home page.
Click on View. You'll see the same form that you saw before when creating a post but the title input is disabled and the editor is not available anymore.
Let's try giving the user edit permissions now. Log out and login with the first user again. This time, click on "Change edit access" on the post. Give the new user access and click Save.
Again, logout and login as the new user. Then, click on View on the post in the home page. You should now be able to edit the post.
You can even try going back to the first user and removing the edit access for the new user on the post. They will no longer be able to edit the post.
RBAC adds abilities and roles to users which allows you to manage permissions in your app. In this tutorial, you learned how to use Bouncer to implement RBAC in Laravel, allowing or disallowing permissions and abilities for users.
Be sure to check out Bouncer's documentation to learn more about what you can do with the package.
21