Inertia.js for the bridge, Pinia for state, Spatie roles for permissions, Reverb for real-time, Vite for assets, Pest for tests — the complete opinionated stack for a full-stack Laravel + Vue application, with the folder structure, the shared type patterns, and the decisions that teams regret making differently six months later.
Every full-stack Laravel + Vue project starts the same way: a resources/js folder, a handful of components, a couple of API endpoints. Six months in, half these projects are fighting themselves — a REST API layer nobody needed because the frontend and backend live in the same repo, Vuex state duplicating what a controller already knew, permission checks written three different ways across three different features, and a WebSocket integration bolted on as an afterthought because “real-time” wasn’t in the original plan.
None of that is a Vue problem or a Laravel problem. It’s an architecture problem — decisions made independently, by different people, at different times, without a shared answer to “how does data get from the database to the screen, and back.” This post is that shared answer: Inertia as the only bridge between Laravel and Vue, Pinia for the state Inertia genuinely doesn’t cover, Spatie’s permission package used consistently instead of ad-hoc role checks, Reverb for real-time wired in from day one instead of retrofitted, Vite for the build, Pest for tests that read like the assertions they are — plus the folder structure and the specific decisions that are expensive to reverse once a team has built fifty features on top of them.
Why Inertia, and Why That Means No REST API
The single most consequential decision in this stack is also the one most tutorials gloss over: Inertia.js means there is no JSON API between the Laravel backend and the Vue frontend. Controllers return Inertia responses directly. There’s no separate /api/posts endpoint for the frontend to call, no request/response serialization layer that exists purely to satisfy an API contract nobody outside this app consumes.
// app/Http/Controllers/PostController.php
class PostController extends Controller
{
public function index(Request $request): Response
{
return Inertia::render('Posts/Index', [
'posts' => Post::query()
->with('author:id,name')
->when($request->search, fn ($q, $search) =>
$q->where('title', 'like', "%{$search}%"))
->latest()
->paginate(20)
->withQueryString(),
]);
}
}
<!-- resources/js/pages/Posts/Index.vue -->
<script setup lang="ts">
import { router } from '@inertiajs/vue3'
import type { Post, PaginatedResponse } from '@/types'
defineProps<{
posts: PaginatedResponse<Post>
}>()
</script>
The controller queries the database and hands props directly to a Vue page component. No PostResource::collection() shaping JSON for an API consumer that doesn’t exist, no separate frontend fetch call with its own loading/error state for data the backend already had ready. This is the entire value proposition of the “modern monolith” pattern — one round trip, one source of truth for what a page needs, and a controller that reads like what it actually does instead of what a generic REST convention says it should do.
The mistake teams make repeatedly: building a REST API “just in case,” alongside the Inertia routes, for a mobile app or third-party integration that doesn’t exist yet. This doubles the surface area of every feature — the Inertia controller action and a parallel API controller action, both needing to stay in sync, both needing their own authorization checks, both needing their own tests — for a consumer that’s hypothetical. If a real API need shows up later — an actual mobile app, an actual public API — build it then, as its own versioned surface with its own authentication (Sanctum tokens, not session cookies), not as a parallel path maintained defensively from day one.
Wayfinder: closing the type gap between routes and requests
The one real gap in an Inertia-only setup is that route URLs and their expected params live in PHP, and the frontend calling them has traditionally had to know that structure by convention, not by type. Laravel’s Wayfinder package closes this by generating TypeScript functions from actual route definitions:
import { store } from '@/actions/App/Http/Controllers/PostController'
router.post(store().url, form)
// TypeScript knows what store() expects because it's generated from the route,
// not hand-maintained as a guess
This is worth wiring in early. Retrofitting typed route calls after fifty components have hardcoded route('posts.store') strings by hand is a mechanical, tedious migration — cheap to avoid, annoying to do later.
Pinia — For the State Inertia Doesn’t Cover
Inertia owns page-level data: whatever a controller passes as props is the source of truth for that page, and navigating to a new page means a new server round trip with fresh props. Reaching for Pinia to duplicate that is the most common state-management mistake in this stack.
// ❌ Duplicating what Inertia already gives you as a prop
const postsStore = defineStore('posts', () => {
const posts = ref<Post[]>([])
async function fetchPosts() {
const { data } = await axios.get('/api/posts')
posts.value = data
}
return { posts, fetchPosts }
})
This re-introduces the API layer the previous section just argued against, and now there are two sources of truth for “what posts exist” — the Inertia props on the page, and whatever’s sitting in this store, which can drift out of sync the moment either one updates without the other.
Pinia earns its place for state that’s genuinely not page-scoped — state that needs to persist and stay consistent as the user navigates between Inertia pages, which by definition can’t live in page props because page props get replaced on every navigation.
// stores/notifications.ts — genuinely cross-page state
export const useNotificationStore = defineStore('notifications', () => {
const unread = ref<Notification[]>([])
const unreadCount = computed(() => unread.value.length)
function add(notification: Notification) {
unread.value.unshift(notification)
}
function markRead(id: string) {
unread.value = unread.value.filter(n => n.id !== id)
}
return { unread, unreadCount, add, markRead }
})
// stores/ui.ts — genuinely cross-page state
export const useUiStore = defineStore('ui', () => {
const sidebarCollapsed = ref(localStorage.getItem('sidebar-collapsed') === 'true')
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
localStorage.setItem('sidebar-collapsed', String(sidebarCollapsed.value))
}
return { sidebarCollapsed, toggleSidebar }
})
A notification bell that needs to keep its unread count while the user clicks through five different pages, a sidebar-collapsed preference, an in-progress multi-step form that shouldn’t reset on a partial navigation, a real-time connection state from Reverb — these are legitimate Pinia territory because they outlive any single page’s props by design. The test before creating a store: if this data could instead be a prop on the page that needs it, it should be a prop, not a store. If it needs to survive navigation to a page that didn’t ask for it, it’s a store.
Spatie Permissions — One Pattern, Enforced Everywhere
Role and permission checks that grow organically end up in three different shapes across a codebase: a user.role === 'admin' string comparison in one controller, an if ($user->is_admin) boolean column check in another, and a proper policy in a third. None of these are wrong in isolation. Having all three in one codebase means nobody can answer “does this user have access” without checking which pattern this particular feature happened to use.
Spatie’s laravel-permission package, used consistently, gives one vocabulary for the whole app:
// database/seeders/RolesAndPermissionsSeeder.php
$editor = Role::create(['name' => 'editor']);
$editor->givePermissionTo(['posts.create', 'posts.edit', 'posts.publish']);
$admin = Role::create(['name' => 'admin']);
$admin->givePermissionTo(Permission::all());
// app/Policies/PostPolicy.php — policies check permissions, not roles directly
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->can('posts.edit')
&& ($user->id === $post->author_id || $user->hasRole('admin'));
}
}
Policies stay the single enforcement point on the backend — $this->authorize('update', $post) in a controller, every time, no direct $user->hasRole() scattered through business logic that a policy should own instead.
The part that gets skipped in most write-ups: the frontend needs the same permission vocabulary, shared as data, not re-implemented as logic.
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user(),
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
],
]);
}
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3'
const page = usePage()
const can = (permission: string) =>
page.props.auth.permissions.includes(permission)
</script>
<template>
<button v-if="can('posts.edit')" @click="editPost">Edit</button>
</template>
can('posts.edit') here is checking a list of strings the server already computed — it is not re-deriving authorization logic in JavaScript. This distinction matters: the Vue check controls what’s shown, purely for UX; the actual authorization decision was made once, in the PostPolicy, on the server, and the frontend permission list is a reflection of that decision, never a parallel source of truth for it. Hiding a button with v-if="can(...)" and skipping the $this->authorize() call in the corresponding controller action is the specific mistake this pattern is meant to prevent — the button being hidden is not the security boundary.
Reverb — Real-Time From Day One, Not Retrofitted
Real-time features bolted onto an app that wasn’t built with them in mind tend to arrive as a single WebSocket connection wired directly into whichever component needed live updates first, with no shared pattern for the next five features that also want them. Reverb, Laravel’s own WebSocket server, is worth wiring into the architecture from the start even if the first release only uses it for one feature.
// app/Events/PostPublished.php
class PostPublished implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Post $post) {}
public function broadcastOn(): Channel
{
return new PrivateChannel("teams.{$this->post->team_id}.posts");
}
public function broadcastAs(): string
{
return 'post.published';
}
}
// resources/js/composables/useEcho.ts — one shared entry point for every feature
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'
window.Pusher = Pusher
export const echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT,
forceTLS: true,
})
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { echo } from '@/composables/useEcho'
import { useNotificationStore } from '@/stores/notifications'
const props = defineProps<{ teamId: number }>()
const notifications = useNotificationStore()
let channel: ReturnType<typeof echo.private>
onMounted(() => {
channel = echo.private(`teams.${props.teamId}.posts`)
.listen('.post.published', (e: { post: Post }) => {
notifications.add({ id: crypto.randomUUID(), message: `${e.post.title} was published` })
})
})
onUnmounted(() => {
echo.leave(`teams.${props.teamId}.posts`)
})
Two things worth being deliberate about from the start: private channels authorized against the same policy layer the rest of the app uses (routes/channels.php checking team membership the same way a controller would), and always leaving the channel in onUnmounted. Skipping the cleanup is the same class of bug as an unstopped Vue watcher — a component that mounts and unmounts repeatedly (a modal opened and closed, a page visited multiple times in a session) stacks up listeners that never go away, and six weeks later that shows up as duplicate notifications or unexplained memory growth, not as an obvious crash.
Vite — The Build Layer Nobody Should Have to Think About Twice
Vite’s role in this stack is small by design: transform and bundle, dev server with HMR, nothing more architecturally interesting than that. The decisions worth making explicit up front are about structure, not Vite configuration itself.
// vite.config.ts
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
laravel({
input: ['resources/js/app.ts'],
ssr: 'resources/js/ssr.ts',
refresh: true,
}),
vue(),
tailwindcss(),
],
resolve: {
alias: {
'@': '/resources/js',
},
},
})
Tailwind v4’s Vite plugin replaces the old PostCSS config entirely — no tailwind.config.js full of content globs to keep in sync with wherever components happen to live. The one decision worth locking down here rather than letting it drift: SSR from the start if SEO or first-paint performance matters for any part of the app, because retrofitting resources/js/ssr.ts after every page component has grown organic client-only assumptions (browser-only globals accessed at the top of a <script setup> block, for instance) is a page-by-page audit, not a config change.
Pest — Tests That Read Like the Behavior They’re Verifying
Pest’s value in this stack isn’t syntax sugar over PHPUnit — it’s that the resulting test file reads as a description of behavior, which matters more in an Inertia app than a typical API-only Laravel app, because the thing being tested is often “does this page get the right props,” not just “does this endpoint return the right JSON.”
// tests/Feature/PostControllerTest.php
it('shows only published posts to non-admin users', function () {
Post::factory()->count(3)->published()->create();
Post::factory()->count(2)->draft()->create();
$user = User::factory()->create();
$this->actingAs($user)
->get(route('posts.index'))
->assertInertia(fn (Assert $page) => $page
->component('Posts/Index')
->has('posts.data', 3)
);
});
it('prevents editing a post the user does not own', function () {
$post = Post::factory()->create();
$otherUser = User::factory()->create();
$this->actingAs($otherUser)
->put(route('posts.update', $post), ['title' => 'Hijacked'])
->assertForbidden();
expect($post->fresh()->title)->not->toBe('Hijacked');
});
assertInertia() checking the actual component name and prop shape is the assertion that matters in this stack specifically — a test that only checks the HTTP status code passes even if the controller silently started rendering the wrong page component, which is a real regression an Inertia app can introduce that a traditional API test wouldn’t catch.
// tests/Feature/PostPolicyTest.php — permission logic tested once, at the policy
it('allows editors to publish posts', function () {
$editor = User::factory()->create();
$editor->assignRole('editor');
$post = Post::factory()->create();
expect($editor->can('update', $post))->toBeTrue();
});
it('denies viewers from publishing posts', function () {
$viewer = User::factory()->create();
$viewer->assignRole('viewer');
$post = Post::factory()->create();
expect($viewer->can('update', $post))->toBeFalse();
});
Testing permission logic at the policy level, once, rather than re-testing “can an editor edit a post” inside every controller test that happens to touch posts, is what keeps the test suite from growing quadratically with the number of features that share the same permission checks.
The Folder Structure
app/
Actions/ # single-purpose classes for non-trivial write operations
Events/ # broadcast events (Reverb)
Http/
Controllers/ # thin — delegate to Actions, return Inertia::render()
Middleware/
Requests/ # Form Request validation, one per action
Models/
Policies/ # the single source of truth for authorization
Providers/
resources/js/
actions/ # Wayfinder-generated, do not hand-edit
components/
ui/ # generic, reusable, no business logic
posts/ # feature-scoped components, business logic allowed
composables/ # useEcho, useCan, shared reactive logic
layouts/
pages/ # one file per Inertia::render() target — mirrors controller structure
stores/ # Pinia — only genuinely cross-page state
types/ # shared TypeScript types, see below
routes/
web.php # Inertia routes — the only routes for the actual app
channels.php # Reverb channel authorization
api.php # empty, or genuinely external consumers only
tests/
Feature/ # Inertia response + policy behavior
Unit/ # Actions, isolated logic
The rule that keeps this structure from drifting: resources/js/pages/ mirrors app/Http/Controllers/ one-to-one. PostController::index() renders Posts/Index, PostController::edit() renders Posts/Edit. When that mapping breaks — a controller action rendering a page from an unrelated folder, or a page component with no controller action that renders it — it’s usually the first sign a feature grew past the structure it started in, and worth fixing before the next person copies the inconsistency into a new feature.
The Shared Type Pattern
The type gap that causes the most repeated bugs in this stack is the Laravel model and its TypeScript representation drifting apart silently — a column renamed in a migration, a resource that stops including a field, and the frontend type still claiming it’s there until a component breaks at runtime with undefined.
// resources/js/types/models.ts
export interface Post {
id: number
title: string
slug: string
body: string
published_at: string | null
author: Pick<User, 'id' | 'name'>
}
export interface PaginatedResponse<T> {
data: T[]
links: { url: string | null; label: string; active: boolean }[]
meta: { current_page: number; last_page: number; total: number }
}
Hand-maintaining this against Laravel API Resources works, but it’s exactly the kind of thing that drifts the moment two people are working on the same feature and only one of them remembers to update the type. Generating these types from PHP — via spatie/laravel-typescript-transformer or an equivalent — and running that generation as part of the build, not as a manual step someone has to remember, closes that gap the same way Wayfinder closes it for routes. The team-level decision worth making explicit early: is a TypeScript type ever hand-written for something that has a PHP equivalent, or is it always generated? Mixing both approaches across a codebase means nobody can trust a type without checking which category it falls into.
The Decisions Teams Regret Making Differently Six Months Later
Building a REST API “just in case,” alongside Inertia routes. Doubles maintenance for a consumer that usually never materializes. Build the API when a real need shows up, versioned and separately authenticated.
Reaching for Pinia before checking if it’s just page props. Creates two sources of truth for the same data. The test is simple: does this need to survive navigation to a page that didn’t ask for it? If not, it’s a prop.
Role checks written three different ways across the codebase. user.role === 'admin' in one file, $user->is_admin in another, a real policy in a third — pick one vocabulary (Spatie roles/permissions enforced through policies) and never let a shortcut check bypass it, even for “just this one internal admin page.”
Hiding UI with a permission check but skipping $this->authorize() on the corresponding backend action. The frontend check is UX. The policy is the security boundary. Confusing which one is which is how an authenticated user reaches an action they were never supposed to see a button for.
Wiring Reverb into the first feature that needs it, without a shared Echo entry point or a channel-authorization pattern. The second and third real-time feature either copy that ad-hoc setup or invent their own — pick the shared composable and the policy-backed channel pattern before the second feature needs it, not after three different ones exist.
Deferring SSR until SEO becomes a problem. Retrofitting SSR onto page components that have grown client-only assumptions is a page-by-page audit. Deciding once, early, whether SSR is in scope, is cheaper than deciding it later under pressure.
Hand-maintaining TypeScript types against Laravel models instead of generating them. Works fine until two people are touching the same feature and the types silently drift from what the backend actually sends.
None of these are dramatic mistakes on their own. Each one is a small, reasonable-looking shortcut the first time it’s taken. The cost shows up later, multiplied by however many features copied the same shortcut before someone noticed the pattern wasn’t a pattern — it was just the first version of the codebase nobody went back to fix.
