DEV Community

Faisal Malik
Faisal Malik

Posted on

Roles & Module-Based Permissions in VUE, Laravel & Tailwind

I recently built a role-based and module-based CMS using Laravel 12 for the backend and Vue 3 with Tailwind CSS for the frontend. The system supports fine-grained permissions, where users can have multiple roles, and their access is dynamically controlled based on assigned modules and components. Authentication is handled securely with Laravel Sanctum, and the frontend leverages the Composition API for a modern, reactive experience.

You can explore the full project on GitHub and try it out here: cms-permission-vue-laravel

Step 1: Set up Sanctum for API Authentication
We'll use the Spatie Laravel Permission package. It’s widely used and battle-tested.

1. Install Laravel Sanctum:

composer require laravel/sanctum
Enter fullscreen mode Exit fullscreen mode

2. Publish config:

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
Enter fullscreen mode Exit fullscreen mode

3. Run migrations:

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Step 2: Install Spatie Role & Permission

composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Update User model:

use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    use HasRoles;
    ....
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Add Modules & Components
1. Module Migration

php artisan make:migration create_modules_table 
Enter fullscreen mode Exit fullscreen mode

Update Migration database/migrations/xxxx_xx_xx_xxxx_create_modules_table.php

<?php

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

return new class extends Migration {
    public function up(): void
    {
        Schema::create('modules', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug'); 
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('modules');
    }
};
Enter fullscreen mode Exit fullscreen mode
php artisan make:migration create_modules_components_table
Enter fullscreen mode Exit fullscreen mode

2. Components Migration
Update Migration database/migrations/xxxx_xx_xx_xxxx_create_modules_components_table.php

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('modules_components', function (Blueprint $table) {
            $table->id();

            // Foreign key to modules table (optional)
            $table->unsignedBigInteger('module_id');

            $table->string('name');
            $table->string('slug'); // Not unique

            $table->timestamps();

            // Optional foreign key constraint
            $table->foreign('module_id')
                  ->references('id')
                  ->on('modules')
                  ->onDelete('cascade');
        });
    }

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

3. Alter Permission Migration

php artisan make:migration create_permission_alter_table 
Enter fullscreen mode Exit fullscreen mode

Update Migration database/migrations/xxxx_xx_xx_xxxx_create_permission_alter_table.php


<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::table('permissions', function (Blueprint $table) {
            // Example: Add new columns
            $table->unsignedBigInteger('module_id')->after('id');
            $table->unsignedBigInteger('component_id')->after('module_id');
            $table->string('label')->after('component_id');

        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('permissions', function (Blueprint $table) {
            // Drop the columns added in up()
            $table->dropColumn('module_id');
            $table->dropColumn('component_id');
            $table->dropColumn('label');
        });
    }
};
Enter fullscreen mode Exit fullscreen mode

4. Create Models for Module, Components, Permissions

php artisan make:model Module
php artisan make:model ModuleComponent
php artisan make:model ModuleHasPermission
Enter fullscreen mode Exit fullscreen mode

5. Create Seeder

php artisan make:seeder DatabaseSeeder
Enter fullscreen mode Exit fullscreen mode

Copy & paste the below function in database/seeders/DatabaseSeeder.php

<?php

namespace Database\Seeders;

use App\Models\User;
use App\Models\Modules\Module;
use App\Models\Modules\ModuleComponent;
use App\Models\Modules\Permission;
use App\Models\Modules\ModuleHasPermission;
use Spatie\Permission\Models\Role;
use Illuminate\Support\Facades\Hash;
use Illuminate\Database\Seeder;

class DatabaseNewSeeder extends Seeder
{
    public function run(): void
    {
        // Create default user
        $user = User::factory()->create([
            'name' => 'Test Admin',
            'email' => 'admin@example.com',
            'password' => Hash::make('12345678'),
        ]);

        // ------------------
        // Create roles
        // ------------------
        $adminRole = Role::firstOrCreate(['name' => 'admin']);
        $userRole  = Role::firstOrCreate(['name' => 'user']);

        // Assign admin role to test user
        $user->syncRoles([$adminRole->name]);

        // ------------------
        // Modules + Components + Permissions
        // ------------------
        $modules = [
            'Users' => ['Details', 'Permissions'],
            'Tasks' => ['List'],
            'Settings' => ['Details']
        ];

        foreach ($modules as $moduleName => $components) {
            $module = Module::create([
                'name' => $moduleName,
                'slug' => strtolower($moduleName),
            ]);

            foreach ($components as $componentName) {
                $component = ModuleComponent::create([
                    'module_id' => $module->id,
                    'name' => $componentName,
                    'slug' => strtolower($componentName),
                ]);

                // Create permissions for each component
                $crudLabels = ['View', 'Add', 'Edit', 'Delete'];
                foreach ($crudLabels as $label) {
                    $permissionName = strtolower($moduleName) . '.' . strtolower($componentName) . '.' . strtolower($label);
                    $permission = Permission::create([
                        'module_id' => $module->id,
                        'component_id' => $component->id,
                        'label' => $label,
                        'name' => $permissionName,
                        'guard_name' => 'web',
                    ]);

                    ModuleHasPermission::create([
                        'permission_id' => $permission->id,
                        'model_id' => $user->id,
                        'model_type' => User::class,
                    ]);
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Run the seeder:

php artisan db:seed --class=DatabaseSeeder
Enter fullscreen mode Exit fullscreen mode

Step 5: Create Auth API
1. Create AuthController

php artisan make:controller AuthController
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AuthController extends Controller
{
    public function login(Request $request)
    {
        $request->validate([
            'email'=>'required|email',
            'password'=>'required'
        ]);

        $user = User::where('email', $request->email)->first();

        if (!$user || !Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['Invalid credentials.']
            ]);
        }

        $token = $user->createToken('api-token')->plainTextToken;

        return response()->json([
            'user'=>$user,
            'token'=>$token
        ]);
    }
    public function show_permissions()
    {
        $auth = Auth::user(); 
        $admin['detail'] = $auth;
        $admin['roles'] = $auth->getRoleNames();
        $admin['permissions'] = $auth->getPermissionNames();
        return response()->json($admin, '201');
    }
    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();
        return response()->json(['message'=>'Logged out']);
    }
}

Enter fullscreen mode Exit fullscreen mode

2. Create UserController

php artisan make:controller UserController
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Illuminate\Http\Request;

class UserController extends Controller
{
   public function index()
    {  
        $users = User::get();
        return response()->json($users, 200);
    }

    public function store(Request $request)
    {
        $request->validate([
            'role'=>'required',
            'name'=>'required',
            'email'=>'required|email|unique:users',
            'password'=>'required|min:6'
        ]);

        $user = User::create([
            'name'=>$request->name,
            'email'=>$request->email,
            'password'=>Hash::make($request->password),
        ]);

        $user->assignRole($request->role); 

        return response()->json($user, 201);
    }

    public function update(int $id, Request $request)
    {

        $user = User::findOrFail($id);

        $request->validate([
            'role' => 'required|string', 
            'name'  => 'required|string|max:255',
            'email' => [
                'required',
                'email',
                Rule::unique('users')->ignore($user->id),
            ],
        ]);

        $user->update([
            'name'  => $request->name,
            'email' => $request->email,
        ]);

        $user->syncRoles([$request->role]);

        return response()->json($user, 201);
    }

    public function show(int $id)
    {
        $user = User::findOrFail($id); 
        $user->role = $user->getRoleNames();
        return response()->json($user, 201);
    }

    public function destroy(int $id)
    {
        $user = User::findOrFail($id);
        $delete =  $user->delete();

        return response()->json($delete, 201);
    }
}

Enter fullscreen mode Exit fullscreen mode

3. Create ModuleController

php artisan make:controller ModuleController
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Models\Modules\Module;
use App\Models\Modules\ModuleComponent;
use App\Models\Modules\Permission;
use App\Models\Modules\ModuleHasPermission;

class ModuleController extends Controller
{

    public function index()
    {
        $modules = Module::get();
        foreach($modules as $key => $row){
            $data['module_id'] = $row->id;
            $data['module_name'] = $row->label;
            $permissions = Permission::where('module_id', $row->id)->get();
            $data['module_permissions'] = $permissions;
        }             
        return response()->json($module, 201);
    }      
    public function permission(int $user)
    {
        $actions = array('View', 'Add', 'Edit', 'Delete');
        $modules = Module::get();
        foreach($modules as $key => $row){
            $data[$key]['module_id'] = $row->id;
            $data[$key]['module_name'] = $row->name;

            $components = ModuleComponent::where('module_id', $row->id)->get();
            $component = array();
            foreach($components as $i => $comp){
                $component[$i]['component_label'] = $comp->name;
                $action_arry = [];
                foreach($actions as $j => $action){
                    $permission = Permission::where('module_id', $row->id)->where('component_id', $comp->id)->where('label', $action)->first();
                    $per = array();
                    if(isset($permission->id)){
                        $per['per_id'] = $permission->id;
                        $per['per_label'] = $permission->label;
                        $per['per_name'] = $permission->name;
                        $admin_per = ModuleHasPermission::where('model_id', $user)->where('permission_id', $permission->id)->first();
                        if(isset($admin_per->model_id)){
                            $per['check'] = 1;
                        }else{
                            $per['check'] = 0;
                        }
                    }
                    $action_arry[$j] = $per;
                }
                $component[$i]['component_actions'] = $action_arry;
            }
            $data[$key]['module_permissions'] = $component;
        }             
        return response()->json($data, 201);
    }  

    public function store(int $user, Request $request)
    {

        $modules = $request->all();
         foreach($modules as $key => $row){
            foreach($row['module_permissions'] as $i => $comp){
                foreach($comp['component_actions'] as $ind => $val){
                    if(isset($val['check']) && $val['check'] == 1){
                        $admin_per = ModuleHasPermission::where('model_id', $user)->where('permission_id', $val['per_id'])->first();
                        if(empty($admin_per)){
                            $per_create = ModuleHasPermission::create([
                                'model_id' => $user,
                                'permission_id' => $val['per_id'],
                                'model_type' => 'App\Models\User',
                            ]);
                        }
                    }
                    if(isset($val['check']) && $val['check'] == 0){
                        $admin_per = ModuleHasPermission::where('model_id', $user)->where('permission_id', $val['per_id'])->first();
                        if(isset($admin_per->model_id)){
                            $per_delete = ModuleHasPermission::where('model_id', $user)->where('permission_id', $val['per_id'])->delete();
                        }
                    }
                }
            }
        } 


        return response()->json($modules, 201);
    }  
}
Enter fullscreen mode Exit fullscreen mode

4. Add routes routes/api.php:

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\ModuleController;

Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get("/users-permissions", [AuthController::class, "show_permissions"]);

    // Users
    Route::apiResource("/users", UserController::class);
    //Permissions
    Route::get("/users/{user}/permissions", [ModuleController::class, "permission"]);
    Route::post("/users/{user}/permissions", [ModuleController::class, "store"]);
});

Enter fullscreen mode Exit fullscreen mode

Step 6: Set up Vue 3 Frontend

1. Create a Vue 3 project (in the same folder or another folder):

npm create vite@latest frontend
cd frontend
npm install
npm install axios pinia vue-router
Enter fullscreen mode Exit fullscreen mode

2. Axios API Global Config
src/stores/api.js

import axios from 'axios';

axios.defaults.baseURL = 'http://127.0.0.1:8000/api';
axios.defaults.headers.common['Accept'] = 'application/json';

const token = localStorage.getItem('token');
if (token) {
  axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}

export default axios;
Enter fullscreen mode Exit fullscreen mode

3. Set up Pinia Store

src/stores/auth.js

import { defineStore } from 'pinia';
import axios from 'axios';
import api from './api';

const api_url = 'http://127.0.0.1:8000';

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    token: localStorage.getItem('token') || null,
  }),

  getters: {
    isLoggedIn: (state) => !!state.token,
  },

  actions: {
    async login(credentials) {
        axios.get(api_url+'/sanctum/csrf-cookie')
        const res = await axios.post(api_url+'/api/login', credentials);

        this.user = res.data.user;
        this.token = res.data.token;

        localStorage.setItem('token', this.token);
        axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
    },
    async logout() {
      await api.post('/logout');

      this.user = null;
      this.token = null;

      localStorage.removeItem('token');
      delete axios.defaults.headers.common['Authorization'];
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

src/stores/permission.js


import { defineStore } from 'pinia'
import api from './api';

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    user: null,
    roles: [],
    permissions: [],
    loaded: false,
  }),
  actions: {
    async fetchUser() {
      await api.get(`/users-permissions`)
      .then((resp) => {            
        this.user = resp.data.detail
        this.roles = resp.data.roles
        this.permissions = resp.data.permissions
        this.loaded = true
      })
    },
    can(check_permission) {
      return this.permissions.includes(check_permission)
    },
    canRole(check_role) {
      return this.roles.includes(check_role)
    }
  }
})

Enter fullscreen mode Exit fullscreen mode

4. Vue Router with Auth Guard

src/router/index.js

import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { usePermissionStore } from '../stores/permission'
import Login from '../views/Login.vue';
import Dashboard from '../views/Dashboard.vue';
import Task from '../views/Task.vue';

const routes = [
  { path: '/login', component: Login },
  {
    path: '/dashboard',
    component: Dashboard,
    meta: { 
      requiresAuth: true,
    },
  },
  {
    path: '/tasks',
    component: Task,
    meta: { 
      requiresAuth: true,
      permission: ['tasks.list.view']
    },
  },
  { path: '/', redirect: '/dashboard' },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

router.beforeEach(async (to, from, next) => {
  const auth = useAuthStore();
  const adminStore = usePermissionStore()

  // Load user/permissions if needed
  if (!adminStore.loaded && auth.isLoggedIn) {
    await adminStore.fetchUser()
  }

  if (to.matched.some(record => record.meta.requiresAuth)) {
    if (!auth.isLoggedIn) {
      next({
        path: '/login',
        query: { redirect: to.fullPath }
      });
    } else {
      // Check permission and role if defined
      const requiredRoles = to.meta.role
      const requiredPermissions = to.meta.permission
      if (requiredRoles && !requiredRoles.some(r => adminStore.canRole(r))) {
        next({ path: '/unauthorized' })
      } 
      else if (requiredPermissions && !requiredPermissions.some(p => adminStore.can(p))) {
        next({ path: '/unauthorized' })
      } else {
        next()
      }
    }
  } else if (to.matched.some(record => record.meta.guestOnly)) {
    if (auth.isLoggedIn) {
      next({ path: '/' }) // Redirect logged-in user away from guest-only pages
    } else {
      next()
    }
  } else {
    next()
  }
})

export default router;
Enter fullscreen mode Exit fullscreen mode

5. Change main.js

src/main.js

import { createApp } from 'vue'
import { createPinia } from 'pinia';
import router from './router';
import './style.css'
import App from './App.vue'

createApp(App)
  .use(createPinia())
  .use(router)
  .mount('#app');
Enter fullscreen mode Exit fullscreen mode
  1. Login Screen

src/views/Login.vue


<template>

  <div class="min-h-screen flex items-center justify-center">
    <div class="w-full max-w-md bg-white p-8 rounded-xl shadow-lg">
      <h2 class="text-2xl font-bold text-center mb-6 text-gray-800 ">
        Login to your account
      </h2>

      <form @submit.prevent="submit" class="space-y-4">
        <div>
          <label class="block text-sm font-medium text-gray-600">Email</label>
          <input
            v-model="email"
            type="email"
            class=" text-black bg-gray-100 mt-1 w-full px-4 py-2 border-gray-300 border-1 rounded-lg focus:ring focus:ring-indigo-200"
            required
          />
        </div>

        <div>
          <label class="block text-sm font-medium text-gray-600">Password</label>
          <input
            v-model="password"
            type="password"
            class="text-black bg-gray-100 mt-1 w-full px-4 py-2  border-gray-300 border-1 rounded-lg focus:ring focus:ring-indigo-200"
            required
          />
        </div>

        <button
          type="submit"
          class="w-full bg-indigo-600 text-white py-2 rounded-lg hover:bg-indigo-700 transition mt-2"
        >
          Login
        </button>

        <p v-if="error" class="text-red-500 text-sm text-center">
          {{ error }}
        </p>
      </form>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { usePermissionStore } from '@/stores/permission'

const email = ref('');
const password = ref('');
const error = ref('');
const router = useRouter();
const auth = useAuthStore();
const permission = usePermissionStore();

const submit = async () => {
  try {
    await auth.login({ email: email.value, password: password.value });
    permission.fetchUser();
    router.push('/dashboard');
  } catch {
    error.value = 'Invalid credentials';
  }
};
</script>

Enter fullscreen mode Exit fullscreen mode

7. Dashboard Screen
src/Dashboard.vue

<template>
  <div>
    <h1>Dashboard</h1>

    <h1 class="text-3xl font-bold mb-4">Welcome, {{ permissionStore.user?.name }}</h1>

    <p class="text-gray-600 mb-6" v-if="permissionStore.user?.roles[0]?.name">
      Roles: <span class="font-medium text-indigo-600 capitalize">{{ permissionStore.user?.roles[0]?.name }}</span>
    </p>

    <div class="bg-white p-8 rounded-xl shadow mb-5" v-if="permissionStore.canRole('admin')">
      <h1 class="text-3xl font-bold mb-6 text-indigo-700">Admin Panel</h1>
      <p>This is admin section.</p>
    </div>
    <div class="bg-white p-8 rounded-xl shadow mb-5" v-if="permissionStore.can('settings.details.view')">
      <h1 class="text-3xl font-bold mb-6 text-indigo-700">Setting Panel</h1>
      <p>This is settings section.</p>
    </div>
    <div class="bg-white p-8 rounded-xl shadow mb-5" v-if="permissionStore.can('tasks.list.view')">
      <h1 class="text-3xl font-bold mb-6 text-indigo-700">Task Panel</h1>
      <router-link to="/tasks" class="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700" v-if="permissionStore.can('tasks.list.view')" >
        View Tasks
      </router-link>
    </div>

    <button class='bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700' @click="logout">Logout</button>
  </div>
</template>

<script setup>
import { useAuthStore } from '@/stores/auth';
import { usePermissionStore } from '@/stores/permission'
import { useRouter } from 'vue-router';

const auth = useAuthStore();
const permissionStore = usePermissionStore();
const router = useRouter();

const logout = async () => {
  await auth.logout();
  router.push('/login');
};
</script>

Enter fullscreen mode Exit fullscreen mode

8. Task Screen
src/Task.vue

<template>
  <div>
    <div class="flex justify-between items-center mb-6">
      <h1 class="text-2xl font-bold">Tasks</h1>
      <button type="button" v-if="permissionStore.can('tasks.list.add')" class="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700" >+ Add Task</button>
    </div>

    <div class="bg-white shadow rounded-lg overflow-hidden">
      <table class="w-full">
        <thead class="bg-gray-800 text-white">
          <tr>
            <th class="px-4 py-3 text-left">Title</th>
            <th class="px-4 py-3 text-left">Action Date</th>
            <th class="px-4 py-3 text-left">Due Date</th>
            <th class="px-4 py-3 text-left">Status</th>
            <th class="px-4 py-3 text-left">Actions</th>
          </tr>
        </thead>

        <tbody>
          <tr
            v-for="task in tasks"
            :key="task.id"
            class="border-t border-t-gray-300 hover:bg-gray-50"
          >
            <td class="px-4 py-3">{{ task.title }}</td>
            <td class="px-4 py-3">{{ task.action_date }}</td>
            <td class="px-4 py-3">{{ task.due_date }}</td>
            <td class="px-4 py-3">{{ task.status }}</td>
            <td class="px-4 py-3 space-x-2">
             <button type="button" v-if="permissionStore.can('tasks.list.edit')" class="text-indigo-600 hover:underline" >Edit</button>
                <button type="button" @click="deleteTask" v-if="permissionStore.can('tasks.list.delete')" class="text-indigo-600 hover:underline" >Delete</button>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

<script setup>
const tasks = [
  {
    id: 1,
    title: 'Design login page UI',
    action_date: '05-01-2026',
    due_date: '07-01-2026',
    status: 'Pending',
  },
  {
    id: 2,
    title: 'Implement user authentication',
    action_date: '06-01-2026',
    due_date: '10-01-2026',
    status: 'In Progress',
  },
];



const deleteTask = async (id) => {
  if (!confirm('Delete this task?')) return;
};

</script>
Enter fullscreen mode Exit fullscreen mode

Step 7: Test Everything
Run Laravel API:

php artisan serve
Enter fullscreen mode Exit fullscreen mode

Run Vue 3 frontend:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Github:
Check out the full CMS Permission System built with Laravel 12, Vue 3, and Tailwind CSS on GitHub:
https://github.com/malickfaisal/cms-permission-vue-laravel
Explore the code, try it out, and see role-based & module-based permissions in action!

Top comments (0)