DEV Community

Benjamin Delespierre
Benjamin Delespierre

Posted on

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

Discussion (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 Author • Edited on

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...