Laravel Daily's

Authorization in Inertia 3.x: Policies, Gates, and Permission-Aware Vue Components

Illustration of Laravel, Vue, Inertia, shields, gates, and permission badges

Authorization should shape your application from the server outward.

Laravel remains the source of truth. Inertia and Vue receive only the permission data they need to render a useful interface. This keeps your UI clear without moving security rules into JavaScript.

This pattern works well for dashboards, team workspaces, admin panels, and applications that expose a REST API alongside an Inertia frontend. It also fits naturally into the Laravel ecosystem, where your PHP web framework, authentication layer, and frontend can share one authorization model.

Policies and gates: choose the right boundary

Laravel provides two primary authorization tools:

  • Gates handle standalone abilities.
  • Policies organize rules around a model or resource.

Use a gate for questions such as:

  • Can this user view the admin dashboard?
  • Can this user manage billing?
  • Can this user access internal reports?

Use a policy for model-based actions:

  • Can this user update this post?
  • Can this user delete this invoice?
  • Can this user invite members to this team?

Laravel’s authorization documentation describes the distinction clearly. Most applications use both.

Define a policy for resource permissions

Generate a policy with Artisan:

php artisan make:policy PostPolicy --model=Post

Laravel can discover policies automatically when you follow its standard naming conventions. A Post model pairs with App\Policies\PostPolicy.

<?php

namespace App\Policies;

use App\Models\Post;
use App\Models\User;

class PostPolicy
{
    public function create(User $user): bool
    {
        return in_array($user->role, ['editor', 'admin'], true);
    }

    public function update(User $user, Post $post): bool
    {
        return $user->id === $post->user_id
            || $user->role === 'admin';
    }

    public function delete(User $user, Post $post): bool
    {
        return $user->role === 'admin';
    }
}

Policy methods can return booleans or detailed authorization responses. Returning a Response::deny() value lets you attach a useful message to the denial.

Keep these rules independent from Vue. A component should not decide whether a user owns a post. It should receive the result of the server-side decision.

Use gates for application-wide abilities

Gates work well for abilities that do not belong to one Eloquent model.

Define a gate in AppServiceProvider:

<?php

namespace App\Providers;

use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Gate::define('view-admin-dashboard', function (User $user): bool {
            return $user->role === 'admin';
        });
    }
}

You can also add a global administrator rule:

Gate::before(function (User $user, string $ability): ?bool {
    return $user->isAdministrator() ? true : null;
});

Returning null allows Laravel to continue to the relevant gate or policy. Returning true grants the ability immediately.

Authorize before rendering an Inertia page

A Vue component should never be the only place that checks access.

If a user is not allowed to view a page, authorize the request before calling Inertia::render():

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;

class AdminDashboardController extends Controller
{
    public function __invoke(): Response
    {
        Gate::authorize('view-admin-dashboard');

        return Inertia::render('Admin/Dashboard');
    }
}

Gate::authorize() throws an authorization exception when the ability is denied. Laravel converts that exception into a 403 response.

For model actions, use the policy directly:

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;

class PostController extends Controller
{
    public function edit(Post $post): \Inertia\Response
    {
        Gate::authorize('update', $post);

        return Inertia::render('Posts/Edit', [
            'post' => $post,
        ]);
    }

    public function update(Request $request, Post $post): RedirectResponse
    {
        Gate::authorize('update', $post);

        $post->update($request->validate([
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string'],
        ]));

        return to_route('posts.show', $post);
    }
}

The check belongs in both methods. Hiding an Edit button does not protect the update endpoint.

You can also authorize at the route level:

use App\Models\Post;
use Illuminate\Support\Facades\Route;

Route::put('/posts/{post}', function (Post $post) {
    // The request is authorized before this callback runs.
})->can('update', 'post');

This is especially useful when you are building a Laravel API and an Inertia application over the same domain rules. Whether the request comes from a Vue page, a mobile client, or a REST API consumer, the server applies the same policy.

Illustration of Laravel policies and gates protecting Vue resources

Pass global permissions through shared props

Inertia’s HandleInertiaRequests middleware is a convenient place for permissions used across many pages.

Use it for navigation items, global actions, and layout-level controls. Do not load an entire permission system into every response.

<?php

namespace App\Http\Middleware;

use App\Models\Post;
use Illuminate\Http\Request;
use Inertia\Middleware;

class HandleInertiaRequests extends Middleware
{
    public function share(Request $request): array
    {
        $user = $request->user();

        return [
            ...parent::share($request),

            'auth' => [
                'user' => $user?->only([
                    'id',
                    'name',
                    'email',
                    'role',
                ]),

                'can' => [
                    'viewAdminDashboard' => $user?->can(
                        'view-admin-dashboard'
                    ) ?? false,

                    'createPost' => $user?->can(
                        'create',
                        Post::class
                    ) ?? false,
                ],
            ],
        ];
    }
}

Inertia’s shared data documentation recommends using shared data carefully. It is included with every response, so keep the structure small and stable.

A flat auth.can object works well for global abilities. For larger applications, you can group permissions by domain:

'can' => [
    'posts' => [
        'create' => $user?->can('create', Post::class) ?? false,
    ],
    'billing' => [
        'manage' => $user?->can('manage-billing') ?? false,
    ],
],

Use lazy closures when resolving a permission requires an expensive query. Avoid sending roles, internal permission tables, or sensitive policy details to the browser.

Pass record-specific permissions from controllers

Global props cannot answer questions about a specific record. A user may edit one post but not another.

Return those decisions with the page props:

public function show(Post $post): \Inertia\Response
{
    $user = request()->user();

    return Inertia::render('Posts/Show', [
        'post' => $post,
        'can' => [
            'update' => $user->can('update', $post),
            'delete' => $user->can('delete', $post),
        ],
    ]);
}

This follows Inertia’s authorization guidance: perform authorization on the server, then pass the result to the page.

Illustration of Laravel server props flowing into permission-aware Vue components

Use can() checks inside Vue components

Inertia exposes shared data through usePage().

<script setup lang="ts">
import { computed } from 'vue'
import { usePage } from '@inertiajs/vue3'

const page = usePage()

const auth = computed(() => page.props.auth)
</script>

<template>
    <aside>
        <a v-if="auth.can.viewAdminDashboard" href="/admin">
            Admin dashboard
        </a>

        <a v-if="auth.can.createPost" href="/posts/create">
            New post
        </a>
    </aside>
</template>

For record-level permissions, accept the can object as a page prop:

<script setup lang="ts">
defineProps<{
    post: {
        id: number
        title: string
    }
    can: {
        update: boolean
        delete: boolean
    }
}>()
</script>

<template>
    <article>
        <h1>{{ post.title }}</h1>

        <a v-if="can.update" :href="`/posts/${post.id}/edit`">
            Edit
        </a>

        <button v-if="can.delete" type="button">
            Delete
        </button>
    </article>
</template>

These checks improve the experience. They remove actions the user cannot perform and keep navigation focused.

They are not security controls. A user can still send a request manually. Laravel must authorize every protected action.

Example: role-based dashboards

A role-based dashboard usually combines a gate, shared props, and conditional Vue rendering.

The controller protects the page:

public function index(): \Inertia\Response
{
    Gate::authorize('view-admin-dashboard');

    return Inertia::render('Admin/Dashboard', [
        'metrics' => $this->metrics->forToday(),
    ]);
}

The layout can hide the navigation link for other users:

<template>
    <nav>
        <a href="/dashboard">Dashboard</a>

        <a
            v-if="$page.props.auth.can.viewAdminDashboard"
            href="/admin"
        >
            Administration
        </a>
    </nav>
</template>

The result is consistent. Unauthorized users do not see the link, and direct requests still receive a 403 response.

Example: team permissions

Team-based applications need one more piece of context: the active team.

A policy should evaluate both the user and the team resource:

<?php

namespace App\Policies;

use App\Models\Team;
use App\Models\User;

class TeamPolicy
{
    public function invite(User $user, Team $team): bool
    {
        return $team->users()
            ->whereKey($user->id)
            ->wherePivotIn('role', ['owner', 'admin'])
            ->exists();
    }
}

Authorize the invitation page and action:

public function create(Team $team): \Inertia\Response
{
    Gate::authorize('invite', $team);

    return Inertia::render('Teams/Invite', [
        'team' => $team,
        'can' => [
            'invite' => true,
        ],
    ]);
}

The Vue page only needs the result:

<button v-if="can.invite" type="submit">
    Invite member
</button>

Laravel’s starter kits also include team-aware application options. Their conventions can provide a useful starting point for current-team routes and membership checks.

Handle 403 responses with an Inertia error page

A denied action should feel intentional, not broken.

Inertia 3 provides an exception handler for rendering custom error pages:

<?php

use Inertia\ExceptionResponse;
use Inertia\Inertia;

Inertia::handleExceptionsUsing(function (
    ExceptionResponse $response
) {
    if (! app()->environment(['local', 'testing'])
        && $response->statusCode() === 403) {
        return $response
            ->render('Errors/403', [
                'status' => 403,
            ])
            ->withSharedData();
    }

    return null;
});

Then create resources/js/Pages/Errors/403.vue:

<script setup lang="ts">
import { Link } from '@inertiajs/vue3'

defineProps<{
    status: number
}>()
</script>

<template>
    <main class="mx-auto max-w-xl py-24 text-center">
        <p class="text-sm font-semibold text-red-600">
            Error {{ status }}
        </p>

        <h1 class="mt-4 text-3xl font-bold">
            You do not have access to this page.
        </h1>

        <p class="mt-4 text-gray-600">
            Return to your dashboard or ask a team administrator for access.
        </p>

        <Link
            href="/dashboard"
            class="mt-8 inline-flex rounded-lg bg-black px-4 py-2 text-white"
        >
            Return to dashboard
        </Link>
    </main>
</template>

Illustration of team permissions and a friendly 403 error page

Keep one authorization model

A reliable Inertia application follows a simple rule:

  1. Define rules in Laravel policies and gates.
  2. Authorize controllers, routes, and API actions on the server.
  3. Share small permission results with Inertia.
  4. Use can checks to shape the Vue interface.
  5. Render a clear 403 page when access is denied.

This avoids duplicated authorization logic across PHP and JavaScript. It also gives your team a consistent foundation for building dashboards, SaaS workspaces, and applications that build REST APIs with PHP while serving a modern Vue frontend.

Laravel’s Vue starter kit provides the frontend structure. Policies, gates, and shared props connect that structure to the permissions your application actually enforces.

Previous
Structured Output in Laravel: Type-Safe JSON from AI Agents with the AI SDK
Next
Optimistic UI in Inertia 3.x: Instant Feedback with useHttp in Laravel + Vue