Courses

Laravel Vue Inertia: Food Ordering Project Step-By-Step

Users, Roles and Permissions

In our system, we are going to have the following roles:

  • admin - Admin user, manages vendors (Restaurant owners). Admin will create Restaurants and their Owner accounts.
  • vendor - Restaurant Owner can manage the restaurant menu and staff members.
  • staff - Staff Member belongs to the Restaurant, is managed by Restaurant Owner, and fulfills customer orders.
  • customer - The customer can place orders, and Staff Members will process the orders.

But to give them access to different features, we need to expand that into a more granular permission system.

In this project, we will not use any external packages, but you may choose to use something like spatie/laravel-permission. It's a personal preference.

Our goal is to have the following database tables:

Seeded permissions table:

DB Permissions

roles table with admin role to begin with:

DB Roles

On the permission_role pivot table, all permissions are assigned to the admin role:

DB Permission Role Pivot

And user with admin role:

DB Role User Pivot

Here's the DB schema we're aiming for:

Let's implement all of that step by step.


Create Permissions Model

Let's create our first Permissions Model with the make:model Artisan command. Flags -ms instructs to create Migration and Seeder for that model.

php artisan make:model Permission -ms

In migration, we add the string column name as follows:

2023_05_31_000001_create_permissions_table.php

public function up(): void
{
Schema::create('permissions', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}

Then update the PermissionSeeder with the following content:

database/seeders/PermissionSeeder.php

namespace Database\Seeders;
 
use App\Models\Permission;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
 
class PermissionSeeder extends Seeder
{
public function run(): void
{
$actions = [
'viewAny',
'view',
'create',
'update',
'delete',
'restore',
'forceDelete',
];
 
$resources = [
'user',
'restaurant',
];
 
collect($resources)
->crossJoin($actions)
->map(function ($set) {
return implode('.', $set);
})->each(function ($permission) {
Permission::create(['name' => $permission]);
});
}
}

Here we do not specify each permission. Instead, we create permissions dynamically.

  • $actions array defines what actions the user will be able to perform on a resource.
  • $resources array defines whom we will perform those actions. In our case, it is just a definition of the model.

We use the crossJoin Collection method to match every action with every resource. The result of such action gives the following output:

[
['user', 'viewAny'],
['user', 'view'],
['user', 'create'],
// etc...
['restaurant', 'forceDelete']
]

As we can see, it produces a set of each value. Later we map them using the implode() method into strings like user.viewAny, user.View and create permission names based on that.


Create Roles Model

Our permissions will be assigned to roles. Let's create the Role model using the Artisan command:

php artisan make:model Role -ms

Roles can have multiple permissions, and a single permission can belong to various roles, so we need the permission_role pivot table. Run the Artisan command to create that:

php artisan make:migration create_permission_role_table

And the same reasoning applies to the role_user pivot table with another Artisan command:

php artisan make:migration create_role_user_table

The role will contain only the name column in its table. Let's update the Migration:

database/migrations/2023_05_31_000002_create_roles_table.php

public function up(): void
{
Schema::create('roles', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}

Then update the permission_role pivot table Migration as follows:

database/migrations/2023_05_31_000003_create_permission_role_table.php

use App\Models\Permission;
use App\Models\Role;
 
// ...
 
public function up(): void
{
Schema::create('permission_role', function (Blueprint $table) {
$table->foreignIdFor(Permission::class)->constrained();
$table->foreignIdFor(Role::class)->constrained();
});
}

The foreignIdFor() method will create keys permission_id and role_id by automatically resolving the class names we provide as an argument.

Alternatively, you could write ->foreignId('permission_id'), it's a personal preference.

In the same fashion, we update the role_user pivot table migration:

2023_05_31_000004_create_role_user_table

use App\Models\Role;
use App\Models\User;
 
// ...
 
public function up(): void
{
Schema::create('role_user', function (Blueprint $table) {
$table->foreignIdFor(Role::class)->constrained();
$table->foreignIdFor(User::class)->constrained();
});
}

Then update the Role Model by adding permissions() and users() relationships as follows:

app/Models/Role.php

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
// ...
 
public function permissions(): BelongsToMany
{
return $this->belongsToMany(Permission::class);
}
 
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}

Now let's define Role names using backed enums. They are convenient to reference the roles elsewhere in the code by their names and not IDs. Also, if you later change role names so you can do it in one place instead, and no other references need to be changed.

Create new RoleName enum as follows:

app/Enums/RoleName.php

namespace App\Enums;
 
enum RoleName: string
{
case ADMIN = 'admin';
case VENDOR = 'vendor';
case STAFF = 'staff';
case CUSTOMER = 'customer';
}

Finally, we can update RoleSeeder with the following content:

database/seeders/RoleSeeder.php

namespace Database\Seeders;
 
use App\Enums\RoleName;
use App\Models\Permission;
use App\Models\Role;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
 
class RoleSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$this->createAdminRole();
}
 
protected function createRole(RoleName $role, Collection $permissions): void
{
$newRole = Role::create(['name' => $role->value]);
$newRole->permissions()->sync($permissions);
}
 
protected function createAdminRole(): void
{
$permissions = Permission::query()
->where('name', 'like', 'user.%')
->orWhere('name', 'like', 'restaurant.%')
->pluck('id');
 
$this->createRole(RoleName::ADMIN, $permissions);
}
}

This may look like an overengineering at first glance, but I already envision we will seed more roles in the future, so to avoid repeating the code, I extracted some of it into smaller methods.

At this point, we seed only the Admin role.

The createRole() method accepts two arguments, the enum RoleName we defined before (see, it already paid off to use the enum!) and a Collection of permissions.

The createAdminRole() method queries for all permissions that begin with user. OR restaurant. and passes them along the RoleName to the createRole() method. We allow the admin user to perform all available actions on models.


Add Roles To User

Now, let's add the relationships to the User model. I also created a few helper methods we would use later in the course.

app/Models/User.php

use App\Enums\RoleName;
use App\Models\Role;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
class User extends Authenticatable
{
// ...
 
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
 
public function isAdmin(): bool
{
return $this->hasRole(RoleName::ADMIN);
}
 
public function isVendor(): bool
{
return $this->hasRole(RoleName::VENDOR);
}
 
public function isStaff()
{
return $this->hasRole(RoleName::STAFF);
}
 
public function isCustomer()
{
return $this->hasRole(RoleName::CUSTOMER);
}
 
public function hasRole(RoleName $role): bool
{
return $this->roles()->where('name', $role->value)->exists();
}
 
public function permissions(): array
{
return $this->roles()->with('permissions')->get()
->map(function ($role) {
return $role->permissions->pluck('name');
})->flatten()->values()->unique()->toArray();
}
 
public function hasPermission(string $permission): bool
{
return in_array($permission, $this->permissions(), true);
}
 
}

Add Admin User

To seed our first admin user, let's create a UserSeeder with the following Artisan command:

php artisan make:seed UserSeeder

And update its contents as follows:

database/seeders/UserSeeder.php

namespace Database\Seeders;
 
use App\Enums\RoleName;
use App\Models\Role;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
 
class UserSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$this->createAdminUser();
}
 
public function createAdminUser()
{
User::create([
'name' => 'Admin User',
'email' => 'admin@admin.com',
'password' => bcrypt('password'),
])->roles()->sync(Role::where('name', RoleName::ADMIN->value)->first());
}
}

As we did with roles, by following the same logic, the admin user is created with a different method, and the role is synchronized via the sync() method by the enum RoleName::ADMIN.


Seed Database

Now it is time to update run() method of DatabaseSeeder as follows:

database/seeders/DatabaseSeeder.php

public function run(): void
{
$this->call([
PermissionSeeder::class,
RoleSeeder::class,
UserSeeder::class,
]);
}

Finally, we can seed the database with all the requirements using the migrate --seed Artisan command:

php artisan migrate --seed

Now if you did everything correctly, all the entries in the database should be populated.

Here's our DB schema for users/roles/permissions:


Register Auth Gates

To fully utilize Laravel's authorization capabilities, we will register our permissions as Gates.

For example, if we want to check if the user is allowed to restaurant.viewAny, then in Controller, we could check it like this:

$this->authorize('restaurant.viewAny');

Typically, gates are defined within the boot method of the App\Providers\AuthServiceProvider class using the Gate facade. Gates always receive a user instance as their first argument and may optionally receive additional arguments such as a relevant Eloquent model.

app/Providers/AuthServiceProvider.php

use App\Models\Permission;
use Illuminate\Support\Facades\Gate;
 
class AuthServiceProvider extends ServiceProvider
{
//...
 
public function boot(): void
{
$this->registerGates();
}
 
protected function registerGates(): void
{
try {
foreach (Permission::pluck('name') as $permission) {
Gate::define($permission, function ($user) use ($permission) {
return $user->hasPermission($permission);
});
}
} catch (\Exception $e) {
info('registerPermissions(): Database not found or not yet migrated. Ignoring user permissions while booting app.');
}
}
}

The closure provided to the Gate definition should always return a boolean that tells if the user can perform a given action.

Our hasPermission() method from the User model will return a true or false whether the user has the following permission.

It may sound complicated for now, but we will return to that when actually using those permissions in other parts of our application. For now, the groundwork for roles/permissions is done.

avatar
Patrik Strišovský

What about to define Gate::before instead of attaching all permissions to admin user.

app/Providers/AuthServiceProvider.php

Gate::before(function (User $user) {
   return $user->isAdmin();
});
avatar

you can do that. then you would have to add additional is_admin column to the database and write more logic for this.

however the point is that admin is part of the permission system, not the some exception along permissions.

avatar
Patrik Strišovský

Ok if admin role is something that can't do everything, then i get it.

But imagine creating one speical role that can do everything, something like super-admin. Then we can define:

Gate::before(function (User $user) {
   return $user->hasRole('super-admin'); // or create an  `isSuperAdmin(...)` function in `User` model
});

No additional column is needed as we can use the role table.

avatar

Yes you can do that if you want. I'm not telling you cannot create a special role for that if you have a need.

avatar
  1. if the project is small it is fine i think. although there might be cases where even "super-admin" should not have access to everything, for example some data that users can manage themselves like patient data.

  2. if project is bigger your approach might fall behind because everyone developing system should know additional information "all roles are normal, BUT NOT THIS ONE". this applies not only for 'super-admin' but for "exceptional models" in general.

  3. there's no good or bad on this take i guess, but it is important to understand the differences.

avatar

isAdmin(), isCustomer(), isStaff() for what? this is not used anywhere

avatar

These methods will be used later. In general it is a good idea to add helper methods upfront, especially when we are going to build features around those roles.

avatar

Can you explain the meaning of the circle and diamond symbols in the DB schema? I'm used to DB Schemas being represented using different symbols - example: https://vertabelo.com/blog/schema-diagram/

avatar

The symbols depend on the database tool that you use. In our case, it is mostly DataGrip that we use and this is the default they do. They indicate the same respective things, with just different symbols.

avatar

PHP Fatal error: Namespace declaration statement has to be the very first statement or after any declare call in the script in C:\xampp8\htdocs\food-de livery\app\Enums\RoleName.php on line 2

how to fix this error

avatar

In your file, you have a namespace App\Enums; line somewhere. It has to go right after the <?php tag. Like this:

<?php

namespace App\Enums;

// ... Your code
avatar

in RoleSeed class can you explain the query, where('name', 'like', 'user.%'), it should be name like admin.%, correct me if I am wrong please

protected function createAdminRole(): void
{
    $permissions = Permission::query()
        ->where('name', 'like', 'user.%')
        ->orWhere('name', 'like', 'restaurant.%')
        ->pluck('id');

    $this->createRole(RoleName::ADMIN, $permissions);
}
	
avatar

It shouldn't be admin.% since we don't have any permissions only for admins in our permissions table. What we have instead is user.* permissions.

This query is meant to take all permissions for users/restaurants and assign them to our admin role