Roles and Permissions in Laravel + Vue + Tailwind: The Complete Full-Stack Implementation

Backend gates, Spatie roles, API middleware, frontend permission directives in Vue, hiding UI elements based on role, and the one architecture decision that keeps your permission logic from spreading into every layer of your application — a complete implementation from database to button.


Permission systems have a sprawl problem. They start as a clean backend concern — a Gate check in a controller, a Policy on a model — and gradually appear everywhere: in controllers, in Blade views, in Vue components, in API responses, in route definitions, in Pinia stores. By the time a mid-sized application is mature, a developer changing a permission has to update six files and hope they found all of them.

The architecture decision that prevents this is deceptively simple: permission data flows in one direction, from the API to the frontend, and is evaluated in two places only — the backend before any action executes, and the frontend before any UI that would trigger the action is shown. Never in both directions for the same check. Never scattered across components. One authoritative source, two application points.

This post implements that architecture completely — Spatie on the backend, a Vue permission system on the frontend, and the API endpoint that connects them.


Backend: Spatie Setup

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

Enable the cache in production:

// config/permission.php
'cache' => [
    'expiration_time' => \DateInterval::createFromDateString('24 hours'),
    'key'             => 'spatie.permission.cache',
    'store'           => 'redis', // not 'file' in production
],

Add the trait to the User model:

use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    use HasRoles;
}

The Permission Seeder

class RoleAndPermissionSeeder extends Seeder
{
    public function run(): void
    {
        app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();

        // Define all permissions grouped by domain
        $permissions = [
            // Projects
            'projects.view', 'projects.create', 'projects.edit', 'projects.delete',

            // Tasks
            'tasks.view', 'tasks.create', 'tasks.edit', 'tasks.delete', 'tasks.assign',

            // Team
            'team.view', 'team.invite', 'team.remove', 'team.manage-roles',

            // Billing
            'billing.view', 'billing.manage',

            // Settings
            'settings.view', 'settings.manage',

            // Admin
            'admin.access', 'admin.impersonate',
        ];

        foreach ($permissions as $permission) {
            Permission::firstOrCreate(['name' => $permission]);
        }

        // Viewer — read only
        Role::firstOrCreate(['name' => 'viewer'])
            ->syncPermissions([
                'projects.view', 'tasks.view', 'team.view', 'billing.view',
            ]);

        // Member — contributor
        Role::firstOrCreate(['name' => 'member'])
            ->syncPermissions([
                'projects.view', 'projects.create',
                'tasks.view', 'tasks.create', 'tasks.edit',
                'team.view',
                'billing.view',
            ]);

        // Admin — full workspace management
        Role::firstOrCreate(['name' => 'admin'])
            ->syncPermissions(
                Permission::whereNotIn('name', ['admin.access', 'admin.impersonate'])
                    ->pluck('name')
            );

        // Owner — everything
        Role::firstOrCreate(['name' => 'owner'])
            ->syncPermissions(Permission::all());
    }
}

The dot-notation naming (projects.view, tasks.create) is the pattern that makes permission checking readable. $user->can('projects.edit') reads like English. It also groups naturally in the frontend permission system.


The API Endpoint That Exposes Permissions to Vue

The architecture’s central piece: one API endpoint that returns everything the frontend needs to know about what the current user can do.

// app/Http/Controllers/Api/AuthController.php

public function me(Request $request): JsonResponse
{
    $user = $request->user()->load('roles');

    return response()->json([
        'data' => [
            'id'          => $user->id,
            'name'        => $user->name,
            'email'       => $user->email,
            'avatar_url'  => $user->avatar_url,
            'roles'       => $user->roles->pluck('name'),
            'permissions' => $user->getAllPermissions()->pluck('name'),
        ],
    ]);
}

getAllPermissions() returns all permissions the user has — from roles AND from direct permission grants. This is the correct method. getPermissionNames() only returns direct permissions. getAllPermissions() is what you want.

Example response:

{
    "data": {
        "id": 1,
        "name": "Sadique Ali",
        "email": "sadique@apnahive.com",
        "roles": ["admin"],
        "permissions": [
            "projects.view",
            "projects.create",
            "projects.edit",
            "projects.delete",
            "tasks.view",
            "tasks.create",
            "tasks.edit",
            "tasks.delete",
            "tasks.assign",
            "team.view",
            "team.invite",
            "team.remove",
            "team.manage-roles",
            "billing.view",
            "billing.manage",
            "settings.view",
            "settings.manage"
        ]
    }
}

The frontend receives this once on login and after any role/permission change. It doesn’t poll. It doesn’t compute permissions client-side. It receives the authoritative list from the server.


Backend: Gates, Policies, and Middleware

Gate Integration

Spatie automatically hooks into Laravel’s Gate. $user->hasPermissionTo('projects.edit') and Gate::allows('projects.edit') and $this->authorize('projects.edit') all work through the same Spatie-backed resolution.

// These are all equivalent:
$user->can('projects.edit')
Gate::allows('projects.edit')
$request->user()->hasPermissionTo('projects.edit')

Policy Integration

Policies handle resource-level authorization — “can this user edit THIS specific project?” They compose with Spatie’s role/permission system:

class ProjectPolicy
{
    public function edit(User $user, Project $project): bool
    {
        // Permission-based check via Spatie
        if (!$user->hasPermissionTo('projects.edit')) {
            return false;
        }

        // Resource-specific check — admin edits any project,
        // member edits only their own
        if ($user->hasRole('admin') || $user->hasRole('owner')) {
            return true;
        }

        return $project->created_by === $user->id;
    }

    public function delete(User $user, Project $project): bool
    {
        return $user->hasPermissionTo('projects.delete')
            && ($user->hasRole(['admin', 'owner']) || $project->created_by === $user->id);
    }
}
// Register in AppServiceProvider::boot()
Gate::policy(Project::class, ProjectPolicy::class);

Route Middleware

// routes/api.php
Route::middleware('auth:sanctum')->group(function () {

    // Permission middleware from Spatie
    Route::middleware('permission:projects.view')->group(function () {
        Route::get('/projects', [ProjectController::class, 'index']);
        Route::get('/projects/{project}', [ProjectController::class, 'show']);
    });

    Route::middleware('permission:projects.create')->group(function () {
        Route::post('/projects', [ProjectController::class, 'store']);
    });

    // Policy-based authorization for resource-specific actions
    Route::put('/projects/{project}', [ProjectController::class, 'update']);
    Route::delete('/projects/{project}', [ProjectController::class, 'destroy']);

    // Role-based middleware for admin-only areas
    Route::middleware('role:admin|owner')->group(function () {
        Route::get('/admin', [AdminController::class, 'index']);
        Route::apiResource('team', TeamController::class);
    });

    // Billing — permission-based
    Route::middleware('permission:billing.manage')->group(function () {
        Route::post('/billing/subscribe', [BillingController::class, 'subscribe']);
        Route::delete('/billing/cancel', [BillingController::class, 'cancel']);
    });
});

The controller still calls $this->authorize() for resource-specific checks. The middleware handles broad permission gates. Both layers are necessary.

class ProjectController extends Controller
{
    public function update(Request $request, Project $project): JsonResponse
    {
        $this->authorize('edit', $project); // Triggers ProjectPolicy::edit()
        // ...
    }

    public function destroy(Project $project): JsonResponse
    {
        $this->authorize('delete', $project); // Triggers ProjectPolicy::delete()
        $project->delete();
        return response()->json(null, 204);
    }
}

Frontend: The Permission System in Vue 3

The Auth Store (Pinia)

The auth store is the single source of truth for permissions on the frontend:

// stores/authStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { AuthApi } from '@/api/authApi'

export interface AuthUser {
    id:          number
    name:        string
    email:       string
    avatar_url:  string | null
    roles:       string[]
    permissions: string[]
}

export const useAuthStore = defineStore('auth', () => {
    const user        = ref<AuthUser | null>(null)
    const isLoading   = ref(false)

    const isAuthenticated = computed(() => !!user.value)

    // Permission checks
    const can = computed(() => (permission: string): boolean => {
        if (!user.value) return false
        return user.value.permissions.includes(permission)
    })

    const canAny = computed(() => (permissions: string[]): boolean => {
        if (!user.value) return false
        return permissions.some(p => user.value!.permissions.includes(p))
    })

    const canAll = computed(() => (permissions: string[]): boolean => {
        if (!user.value) return false
        return permissions.every(p => user.value!.permissions.includes(p))
    })

    // Role checks
    const hasRole = computed(() => (role: string | string[]): boolean => {
        if (!user.value) return false
        const roles = Array.isArray(role) ? role : [role]
        return roles.some(r => user.value!.roles.includes(r))
    })

    async function fetchCurrentUser(): Promise<void> {
        isLoading.value = true
        try {
            const response  = await AuthApi.me()
            user.value      = response.data
        } finally {
            isLoading.value = false
        }
    }

    function setUser(authUser: AuthUser): void {
        user.value = authUser
    }

    function clearUser(): void {
        user.value = null
    }

    return {
        user: readonly(user),
        isLoading: readonly(isLoading),
        isAuthenticated,
        can,
        canAny,
        canAll,
        hasRole,
        fetchCurrentUser,
        setUser,
        clearUser,
    }
})

The Vue Permission Directive

A global v-can directive for hiding elements the user isn’t allowed to see:

// plugins/permissions.ts
import type { App, DirectiveBinding } from 'vue'
import { useAuthStore } from '@/stores/authStore'

export const permissionsPlugin = {
    install(app: App) {
        // v-can="'projects.edit'" — shows element if user has permission
        app.directive('can', {
            mounted(el: HTMLElement, binding: DirectiveBinding<string>) {
                const store = useAuthStore()
                if (!store.can(binding.value)) {
                    el.style.display = 'none'
                    // Alternatively: el.remove() for complete DOM removal
                }
            },
            updated(el: HTMLElement, binding: DirectiveBinding<string>) {
                const store = useAuthStore()
                el.style.display = store.can(binding.value) ? '' : 'none'
            },
        })

        // v-can-any="['projects.edit', 'projects.delete']"
        app.directive('can-any', {
            mounted(el: HTMLElement, binding: DirectiveBinding<string[]>) {
                const store = useAuthStore()
                if (!store.canAny(binding.value)) {
                    el.style.display = 'none'
                }
            },
            updated(el: HTMLElement, binding: DirectiveBinding<string[]>) {
                const store = useAuthStore()
                el.style.display = store.canAny(binding.value) ? '' : 'none'
            },
        })

        // v-role="'admin'" or v-role="['admin', 'owner']"
        app.directive('role', {
            mounted(el: HTMLElement, binding: DirectiveBinding<string | string[]>) {
                const store = useAuthStore()
                if (!store.hasRole(binding.value)) {
                    el.style.display = 'none'
                }
            },
            updated(el: HTMLElement, binding: DirectiveBinding<string | string[]>) {
                const store = useAuthStore()
                el.style.display = store.hasRole(binding.value) ? '' : 'none'
            },
        })
    },
}

Register in main.ts:

// main.ts
import { permissionsPlugin } from '@/plugins/permissions'

const app = createApp(App)
app.use(createPinia())
app.use(permissionsPlugin)
app.mount('#app')

The usePermissions Composable

For programmatic permission checks inside component logic:

// composables/usePermissions.ts
import { useAuthStore } from '@/stores/authStore'

export function usePermissions() {
    const authStore = useAuthStore()

    return {
        can:    authStore.can,
        canAny: authStore.canAny,
        canAll: authStore.canAll,
        hasRole: authStore.hasRole,
    }
}

Frontend Templates: From Navigation to Buttons

Navigation — Hiding Menu Items by Role

<!-- components/AppSidebar.vue -->
<script setup lang="ts">
import { usePermissions } from '@/composables/usePermissions'

const { can, hasRole } = usePermissions()
</script>

<template>
    <nav class="flex flex-col space-y-1 p-4">
        <!-- Always visible to authenticated users -->
        <SidebarLink to="/dashboard" icon="home">
            Dashboard
        </SidebarLink>

        <!-- Projects: visible to anyone with projects.view -->
        <SidebarLink
            v-can="'projects.view'"
            to="/projects"
            icon="folder"
        >
            Projects
        </SidebarLink>

        <!-- Team management: admin and owner only -->
        <SidebarLink
            v-role="['admin', 'owner']"
            to="/team"
            icon="users"
        >
            Team
        </SidebarLink>

        <!-- Billing: billing.view or billing.manage -->
        <SidebarLink
            v-can-any="['billing.view', 'billing.manage']"
            to="/billing"
            icon="credit-card"
        >
            Billing
        </SidebarLink>

        <!-- Settings: settings.view required -->
        <SidebarLink
            v-can="'settings.view'"
            to="/settings"
            icon="cog"
        >
            Settings
        </SidebarLink>

        <!-- Admin panel: role-based, not permission-based -->
        <SidebarLink
            v-role="'admin'"
            to="/admin"
            icon="shield"
            class="border-t border-gray-200 mt-4 pt-4"
        >
            Admin Panel
        </SidebarLink>
    </nav>
</template>

Project Detail Page — Granular Button Visibility

<!-- pages/projects/Show.vue -->
<script setup lang="ts">
import { usePermissions } from '@/composables/usePermissions'
import { useAuthStore } from '@/stores/authStore'

const props  = defineProps<{ project: Project }>()
const { can } = usePermissions()
const auth   = useAuthStore()

// Programmatic check for complex logic
const canEditThisProject = computed(() =>
    can('projects.edit') &&
    (auth.hasRole('admin') || props.project.createdBy.id === auth.user?.id)
)
</script>

<template>
    <div class="max-w-4xl mx-auto p-6">
        <!-- Project header -->
        <div class="flex items-start justify-between mb-6">
            <div>
                <h1 class="text-2xl font-bold text-gray-900">{{ project.name }}</h1>
                <p class="text-gray-500 mt-1">{{ project.description }}</p>
            </div>

            <!-- Action buttons — shown based on permissions -->
            <div class="flex items-center space-x-3">
                <!-- Edit: permission + ownership check -->
                <button
                    v-if="canEditThisProject"
                    @click="openEditModal"
                    class="inline-flex items-center px-4 py-2 text-sm font-medium
                           text-white bg-blue-600 rounded-lg hover:bg-blue-700"
                >
                    <PencilIcon class="w-4 h-4 mr-2" />
                    Edit Project
                </button>

                <!-- Assign tasks: tasks.assign permission -->
                <button
                    v-can="'tasks.assign'"
                    @click="openAssignModal"
                    class="inline-flex items-center px-4 py-2 text-sm font-medium
                           text-gray-700 bg-white border border-gray-300 rounded-lg
                           hover:bg-gray-50"
                >
                    <UserPlusIcon class="w-4 h-4 mr-2" />
                    Assign
                </button>

                <!-- Delete: directive for simple permission checks -->
                <button
                    v-can="'projects.delete'"
                    @click="confirmDelete"
                    class="inline-flex items-center px-4 py-2 text-sm font-medium
                           text-red-700 bg-red-50 border border-red-200 rounded-lg
                           hover:bg-red-100"
                >
                    <TrashIcon class="w-4 h-4 mr-2" />
                    Delete
                </button>
            </div>
        </div>

        <!-- Task list with permission-gated actions -->
        <div class="space-y-3">
            <div
                v-for="task in project.tasks"
                :key="task.id"
                class="bg-white border border-gray-200 rounded-lg p-4"
            >
                <div class="flex items-center justify-between">
                    <span class="font-medium text-gray-900">{{ task.title }}</span>

                    <div class="flex items-center space-x-2">
                        <!-- Edit task: member can edit their own, admin edits any -->
                        <button
                            v-can="'tasks.edit'"
                            @click="editTask(task)"
                            class="p-1.5 text-gray-400 hover:text-blue-600 rounded"
                        >
                            <PencilSquareIcon class="w-4 h-4" />
                        </button>

                        <!-- Delete task: restricted -->
                        <button
                            v-can="'tasks.delete'"
                            @click="deleteTask(task)"
                            class="p-1.5 text-gray-400 hover:text-red-600 rounded"
                        >
                            <TrashIcon class="w-4 h-4" />
                        </button>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

Settings Page — Section-Level Permission Gating

<!-- pages/Settings.vue -->
<script setup lang="ts">
import { usePermissions } from '@/composables/usePermissions'
const { can, hasRole } = usePermissions()
</script>

<template>
    <div class="max-w-3xl mx-auto p-6 space-y-8">
        <h1 class="text-2xl font-bold text-gray-900">Settings</h1>

        <!-- Profile settings — all authenticated users -->
        <SettingsSection title="Profile">
            <ProfileForm />
        </SettingsSection>

        <!-- Workspace settings — settings.manage required -->
        <SettingsSection
            v-can="'settings.manage'"
            title="Workspace"
        >
            <WorkspaceForm />
        </SettingsSection>

        <!-- Team settings — team.manage-roles required -->
        <SettingsSection
            v-can="'team.manage-roles'"
            title="Roles & Permissions"
        >
            <RoleManagement />
        </SettingsSection>

        <!-- Billing settings — billing.manage required -->
        <SettingsSection
            v-can="'billing.manage'"
            title="Billing & Subscription"
        >
            <BillingManagement />
        </SettingsSection>

        <!-- Danger zone — owner only -->
        <SettingsSection
            v-role="'owner'"
            title="Danger Zone"
            danger
        >
            <DeleteWorkspace />
        </SettingsSection>
    </div>
</template>

The Architecture Decision: Two Application Points, One Direction

The anti-pattern this architecture prevents: checking permissions in three places.

// ❌ Permission logic spread across three layers
// Layer 1: Pinia store action
async function deleteProject(id: number) {
    if (!authStore.can('projects.delete')) {
        throw new Error('Forbidden') // permission check in store
    }
    await ProjectApi.delete(id)
}

// Layer 2: Vue component
const handleDelete = async () => {
    if (!can('projects.delete')) {  // permission check again in component
        notify('You cannot delete projects')
        return
    }
    await projectStore.deleteProject(props.project.id)
}

// Layer 3: Template
<button v-if="can('projects.delete')" @click="handleDelete">
    Delete  <!-- permission check a third time in template -->
</button>

The correct pattern — one check in the template (for visibility), one check on the server (for enforcement):

// ✅ Permission logic in two places only

// Frontend: template controls visibility only
<button v-can="'projects.delete'" @click="deleteProject(project.id)">
    Delete
</button>

// Frontend: action calls API directly, no client-side permission check
async function deleteProject(id: number) {
    await ProjectApi.delete(id) // API will 403 if not permitted
}

// Backend: server enforces, always
// ProjectController::destroy() calls $this->authorize('delete', $project)
// Returns 403 if not authorized — client handles it

The template check controls whether the button is visible. The server check controls whether the action succeeds. The client never needs to re-check permissions in application logic. If the server returns 403, handle it globally:

// api/http.ts — global 403 handler
const http = axios.create({ baseURL: '/api' })

http.interceptors.response.use(
    response => response,
    error => {
        if (error.response?.status === 403) {
            // Global handler — shows a toast, not a per-action check
            toast.error('You don\'t have permission to do that.')
        }
        if (error.response?.status === 401) {
            authStore.clearUser()
            router.push('/login')
        }
        return Promise.reject(error)
    }
)

Every 403 from the API goes through this handler. No action needs its own permission pre-check. The template directive handles visibility. The API handler handles enforcement failures.


Vue Router Guards — Permission-Based Navigation

// router/index.ts
import { useAuthStore } from '@/stores/authStore'

router.beforeEach(async (to, from, next) => {
    const authStore = useAuthStore()

    // Routes that require specific permissions
    const requiredPermission = to.meta.permission as string | undefined
    const requiredRole       = to.meta.role as string | string[] | undefined

    if (!authStore.isAuthenticated) {
        return next('/login')
    }

    if (requiredPermission && !authStore.can(requiredPermission)) {
        return next('/403')
    }

    if (requiredRole && !authStore.hasRole(requiredRole)) {
        return next('/403')
    }

    next()
})

Route definitions with permission metadata:

const routes = [
    {
        path: '/projects',
        component: () => import('@/pages/projects/Index.vue'),
        meta: { permission: 'projects.view' },
    },
    {
        path: '/admin',
        component: () => import('@/pages/Admin.vue'),
        meta: { role: 'admin' },
    },
    {
        path: '/billing',
        component: () => import('@/pages/Billing.vue'),
        meta: { permission: 'billing.view' },
    },
    {
        path: '/settings/roles',
        component: () => import('@/pages/settings/Roles.vue'),
        meta: { permission: 'team.manage-roles' },
    },
]

The router guard is the third permission application point — page-level navigation rather than component-level visibility. The guard redirects to /403 (a friendly error page) rather than silently hiding the page. Users who try to navigate directly via URL see an explicit “you don’t have access” page rather than an empty or broken view.


Refreshing Permissions After Role Changes

When an admin changes a user’s role, the frontend’s cached permissions become stale. The pattern that handles this:

// When an admin changes a user's role via the API
async function updateUserRole(userId: number, role: string): Promise<void> {
    await TeamApi.updateRole(userId, role)

    // If the updated user is the current user, refresh permissions
    if (userId === authStore.user?.id) {
        await authStore.fetchCurrentUser()
        // The store now has updated permissions from the server
    }
}

For multi-tab scenarios, use a broadcast channel to refresh permissions across tabs:

// In authStore
const permissionChannel = new BroadcastChannel('permissions')

function notifyPermissionChange(): void {
    permissionChannel.postMessage({ type: 'refresh' })
}

permissionChannel.onmessage = (event) => {
    if (event.data.type === 'refresh') {
        fetchCurrentUser()
    }
}

Testing the Full Stack

Backend — Pest:

it('admin can delete any project', function () {
    $admin   = User::factory()->create();
    $admin->assignRole('admin');
    $project = Project::factory()->create();

    actingAs($admin)
        ->deleteJson("/api/projects/{$project->id}")
        ->assertNoContent();
});

it('member can only delete their own projects', function () {
    $member       = User::factory()->create();
    $member->assignRole('member');
    $ownProject   = Project::factory()->for($member, 'creator')->create();
    $otherProject = Project::factory()->create();

    actingAs($member)
        ->deleteJson("/api/projects/{$ownProject->id}")
        ->assertNoContent();

    actingAs($member)
        ->deleteJson("/api/projects/{$otherProject->id}")
        ->assertForbidden();
});

it('viewer cannot create projects', function () {
    $viewer = User::factory()->create();
    $viewer->assignRole('viewer');

    actingAs($viewer)
        ->postJson('/api/projects', ['name' => 'Test'])
        ->assertForbidden();
});

it('me endpoint returns correct permissions for role', function () {
    $admin = User::factory()->create();
    $admin->assignRole('admin');

    $response = actingAs($admin)
        ->getJson('/api/me')
        ->assertOk();

    expect($response->json('data.permissions'))
        ->toContain('projects.delete')
        ->toContain('team.manage-roles')
        ->not->toContain('admin.impersonate');
});

Frontend — Vitest:

// tests/unit/permissions.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/authStore'

describe('useAuthStore permissions', () => {
    beforeEach(() => {
        setActivePinia(createPinia())
    })

    it('can() returns true for granted permissions', () => {
        const store = useAuthStore()
        store.setUser({
            id: 1, name: 'Test', email: 'test@test.com', avatar_url: null,
            roles: ['admin'],
            permissions: ['projects.edit', 'projects.delete'],
        })

        expect(store.can('projects.edit')).toBe(true)
        expect(store.can('admin.impersonate')).toBe(false)
    })

    it('canAny() returns true when any permission matches', () => {
        const store = useAuthStore()
        store.setUser({
            id: 1, name: 'Test', email: 'test@test.com', avatar_url: null,
            roles: ['member'],
            permissions: ['projects.view'],
        })

        expect(store.canAny(['projects.view', 'projects.edit'])).toBe(true)
        expect(store.canAny(['projects.delete', 'admin.access'])).toBe(false)
    })

    it('hasRole() accepts both string and array', () => {
        const store = useAuthStore()
        store.setUser({
            id: 1, name: 'Test', email: 'test@test.com', avatar_url: null,
            roles: ['admin'],
            permissions: [],
        })

        expect(store.hasRole('admin')).toBe(true)
        expect(store.hasRole(['admin', 'owner'])).toBe(true)
        expect(store.hasRole('owner')).toBe(false)
    })
})

The Complete Architecture in One View

Database (Spatie tables)
  ↓ [permissions cached in Redis]
Laravel Backend
  ├── Route middleware → broad permission gates
  ├── Controller authorization → resource-specific Policy checks
  └── /api/me endpoint → permissions array in API response
          ↓ [one HTTP request on login]
Pinia authStore
  ├── user.permissions array
  └── can() / canAny() / canAll() / hasRole() computed methods
          ↓ [reactive, no API calls]
Vue Templates
  ├── v-can directive → hide/show elements
  ├── v-role directive → role-based visibility
  └── router guard → page-level navigation protection
          ↓ [user triggers action]
API Request
  └── Backend enforces → 403 if not permitted
          ↓ [global 403 handler]
Toast notification
  └── "You don't have permission to do that."

One direction. Two application points (template visibility, server enforcement). Permission data flows from the database through the API to the store to the template. Actions flow from the template to the API where enforcement happens. No permission checks in the middle.

Leave a Reply

Your email address will not be published. Required fields are marked *