DEV Community

Benjamin Delespierre
Benjamin Delespierre

Posted on

13 3

How to implement a basic, role-based, ACL in Laravel

TL;DR

Note: roles will be created in database "on the go", through the Role::wrap method. So there is no need to seed your database with roles to start using them. If you want to seed your database nonetheless with roles, use Role::fromArray(['admin', 'vip', 'guest']).

// on a route:
Route::get('admin', 'AdminController@index')->middleware('role:admin');

// give a role to an user:
$user->addRole('admin');

// remove role from an user:
$user->removeRole('admin');

// select users with `admin` role:
User::whereRole('admin')->get();

// get roles of an user:
$user->roles;   // Collection
$user->roles(); // Query Builder
Enter fullscreen mode Exit fullscreen mode

Tell if an user has role(s):

// true if user has 'admin' role, false otherwise.
$user->hasRole('admin'); 

// true if user has 'guest' or 'vip' (or both) roles, false otherwise. 
$user->hasAnyRole('guest', 'vip'); 

// true if user has both 'admin' and 'accounting' roles, false otherwise.
$user->hasRoles('admin', 'accounting'); 
Enter fullscreen mode Exit fullscreen mode

Implementation

1. Migrations

Run: php artisan make:migration create_roles_table --create=roles

Fill it with:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateRolesTable extends Migration
{
    public function up()
    {
        Schema::create('roles', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name')->unique();
            $table->string('description')->nullable();
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('roles');
    }
}
Enter fullscreen mode Exit fullscreen mode

Run: php artisan make:migration create_role_user_table --create=role_user

Fill it with:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateRoleUserTable extends Migration
{
    public function up()
    {
        Schema::create('role_user', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->foreignId('user_id');
            $table->foreignId('role_id');
            $table->timestamps();

            $table->foreign('user_id')
                ->references('id')->on('users')
                ->onDelete('cascade')->onUpdate('cascade');

            $table->foreign('role_id')
                ->references('id')->on('roles')
                ->onDelete('cascade')->onUpdate('cascade');
        });
    }

    public function down()
    {
        Schema::dropIfExists('role_user');
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Model & Traits

Run: php artisan make:model Role

Fill it with:

namespace App;

use App\HasName;
use Illuminate\Database\Eloquent\Model;

class Role extends Model
{
    use HasName;

    protected $fillable = [
        'name',
        'description',
    ];

    public function users()
    {
        return $this->belongsToMany(User::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

Create a new file app/Concerns/HasName.php

Fill it with

namespace App;

use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Collection;

trait HasName
{
    public static function findFromName(string $name): self
    {
        return self::whereName($name)->firstOrFail();
    }

    public static function findOrCreate(string $name, string $description = ""): self
    {
        try {
            return self::findFromName($name);
        } catch (ModelNotFoundException $e) {
            return self::create(compact('name'));
        }
    }

    public static function wrap($name): self
    {
        if (is_string($name)) {
            $name = self::findOrCreate($name);
        }

        if (is_array($name)) {
            $name = self::firstOrCreate($name);
        }

        if (! $name instanceof self) {
            throw new \InvalidArgumentException("\$name should be string, array, or " . self::class);
        }

        return $name;
    }

    public static function fromArray(array $array): Collection
    {
        return (new Collection($array))->map(fn($item) => self::wrap($item));
    }
}
Enter fullscreen mode Exit fullscreen mode

Create a new file app/Concerns/HasRoles.php

Fill it with:

namespace App;

use App\Role;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;

trait HasRoles
{
    public function roles()
    {
        return $this->belongsToMany(Role::class);
    }

    public function hasRole($role): bool
    {
        return $this->roles()->get()->contains(Role::wrap($role));
    }

    public function hasRoles(...$roles): bool
    {
        foreach (Arr::flatten($roles) as $role) {
            if (! $this->hasRole($role)) {
                return false;
            }
        }

        return true;
    }

    public function hasAnyRole(...$roles): bool
    {
        foreach (Arr::flatten($roles) as $role) {
            if ($this->hasRole($role)) {
                return true;
            }
        }

        return false;
    }

    public function addRole($role): void
    {
        $this->roles()->attach(Role::wrap($role));
    }

    public function removeRole($role): void
    {
        $this->roles()->detach(Role::wrap($role));
    }

    public function scopeWhereRole(Builder $query, $role): Builder
    {
        return $query->whereHas('roles', function($query) use ($role) {
            return $query->where(DB::raw('"roles"."id"'), Role::wrap($role)->id)
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Add this to app/User.php:

namespace App;

use App\Concerns;

class User
{
    use Concerns\HasRoles;
}
Enter fullscreen mode Exit fullscreen mode

3. Middleware

Create a new file app/Http/Middleware/Role.php

Fill it with:

namespace App\Http\Middleware;

use Illuminate\Auth\Access\AuthorizationException;

class Role
{
    public function handle($request, \Closure $next)
    {
        $roles = array_slice(func_get_args(), 2);

        if (! $request->user()->hasAnyRole($roles)) {
            throw new AuthorizationException("You don't have the required role to access this resource.");
        }

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Update app/Http/Kernel.php:

protected $routeMiddleware = [
    'role' => \App\Http\Middleware\Role::class,
];
Enter fullscreen mode Exit fullscreen mode

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

Top comments (2)

Collapse
 
eelcoverbrugge profile image
Eelco Verbrugge

Thanks Benjamin! Helps a lot. I do get this error when migrating the role_user table. Any thoughts?

Laravel 5.6: BadMethodCallException Illuminate\Database\Schema\Blueprint::foreignId does not exist.

Collapse
 
bdelespierre profile image
Benjamin Delespierre • Edited

Hello Eelco,

foreignId is a method introduced in Laravel 7. For 5.6 you need to use unsignedBigInteger. Here's the complete list laravel.com/docs/5.6/migrations#cr...

Speedy emails, satisfied customers

Postmark Image

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay