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
2. Publish config:
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
3. Run migrations:
php artisan migrate
Step 2: Install Spatie Role & Permission
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
Update User model:
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
....
}
Step 3: Add Modules & Components
1. Module Migration
php artisan make:migration create_modules_table
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');
}
};
php artisan make:migration create_modules_components_table
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');
}
};
3. Alter Permission Migration
php artisan make:migration create_permission_alter_table
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');
});
}
};
4. Create Models for Module, Components, Permissions
php artisan make:model Module
php artisan make:model ModuleComponent
php artisan make:model ModuleHasPermission
5. Create Seeder
php artisan make:seeder DatabaseSeeder
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,
]);
}
}
}
}
}
Run the seeder:
php artisan db:seed --class=DatabaseSeeder
Step 5: Create Auth API
1. Create AuthController
php artisan make:controller AuthController
<?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']);
}
}
2. Create UserController
php artisan make:controller UserController
<?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);
}
}
3. Create ModuleController
php artisan make:controller ModuleController
<?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);
}
}
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"]);
});
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
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;
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'];
},
},
});
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)
}
}
})
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;
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');
- 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>
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>
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>
Step 7: Test Everything
Run Laravel API:
php artisan serve
Run Vue 3 frontend:
npm run dev
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)