State management in a Laravel, Vue, and Inertia application does not need to begin with a store.
In many cases, Laravel already owns the state. Inertia delivers that state as page props. Vue then renders it and manages small, local interactions.
Pinia has an important role. It should not become a second database.
The cleanest architecture separates three layers:
- Laravel owns server-side truth.
- Inertia delivers shared and page-specific props.
- Vue and Pinia manage client-side interaction.
This model keeps applications easier to reason about. It also avoids unnecessary API requests, duplicated data, and synchronization problems.

The three layers of state
Laravel: the source of truth
Laravel should own data that must remain correct across requests, users, and devices.
This includes:
- Authenticated users
- Roles and permissions
- Orders and products
- Billing information
- Feature flags
- Notifications
- Database-backed settings
- Validation rules
- Workflow status
Laravel is a productive PHP web framework because it gives these concerns a clear home. Authentication, authorization, validation, queues, events, and database access remain on the server.
The browser should not become responsible for deciding whether a user can access an account or whether an order is paid.
Inertia: the transport layer
Inertia connects Laravel controllers to Vue page components.
A controller returns a page and its data:
use Inertia\Inertia;
public function index()
{
return Inertia::render('Projects/Index', [
'projects' => Project::query()
->latest()
->get(),
'filters' => request()->only('search'),
]);
}
The Vue page receives those values as props:
<script setup lang="ts">
interface Project {
id: number
name: string
status: string
}
interface Props {
projects: Project[]
filters: {
search?: string
}
}
const props = defineProps<Props>()
</script>
<template>
<ul>
<li v-for="project in props.projects" :key="project.id">
{{ project.name }}
</li>
</ul>
</template>
This flow removes the need for a separate onMounted() request in many screens. Laravel loads the data. Inertia carries it to Vue. Vue renders it.
Vue: local interaction
Vue should own state that belongs to one component or one short-lived interaction.
Examples include:
- Whether a modal is open
- The current tab
- A temporary input value
- A loading indicator
- A selected table row
- A dropdown’s open state
Use ref, reactive, and computed values first.
<script setup lang="ts">
import { ref, computed } from 'vue'
const search = ref('')
const showArchived = ref(false)
const hasFilters = computed(() => {
return search.value.length > 0 || showArchived.value
})
</script>
This state does not need to be shared with the entire application. A Pinia store would add structure without solving a real problem.
Sharing data with HandleInertiaRequests
Shared props are available on every Inertia response. They are useful for data that appears throughout the application.
Typical examples include:
- The authenticated user
- Flash messages
- Application name and locale
- Navigation configuration
- Small feature flags
- Global permissions
Laravel’s HandleInertiaRequests middleware is the usual place to define this data. The Inertia shared data documentation recommends keeping shared data focused because it is included with every response.
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'app' => [
'name' => config('app.name'),
'locale' => app()->getLocale(),
],
'auth.user' => fn () => $request->user()
? $request->user()->only([
'id',
'name',
'email',
])
: null,
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
],
]);
}
}
Closures make larger values lazy. Laravel only resolves them when the prop is included in the response.
Namespace shared values carefully. A top-level app, auth, or flash key is easier to understand than a collection of unrelated values.
Do not share a complete user model or a large settings table by default. Return only what the interface needs.
Reading shared props with usePage()
A deeply nested component can read shared props with usePage().
<script setup lang="ts">
import { computed } from 'vue'
import { usePage } from '@inertiajs/vue3'
interface User {
id: number
name: string
email: string
}
interface SharedProps {
app: {
name: string
locale: string
}
auth: {
user: User | null
}
flash: {
success?: string
error?: string
}
}
const page = usePage<SharedProps>()
const user = computed(() => page.props.auth.user)
const successMessage = computed(() => page.props.flash.success)
const appName = computed(() => page.props.app.name)
</script>
<template>
<header>
<span v-if="user">
Signed in as {{ user.name }}
</span>
<span v-if="successMessage">
{{ successMessage }}
</span>
<small>{{ appName }}</small>
</header>
</template>
In Inertia 3.x, page props are reactive. When navigation replaces the current page object, computed values based on page.props update as well.
The generic passed to usePage() provides a TypeScript contract for shared data. You can maintain that contract manually, generate types from your Laravel application, or centralize it in a shared types directory.
This approach gives you editor support without introducing a store. It also exposes mismatches early when a backend response changes.
Flash data deserves special care. It represents a one-time message, not durable application state. Inertia does not persist flash data in browser history, so a success notification does not unexpectedly reappear when a user navigates backward.
For forms, use Inertia’s useForm or <Form> component. These tools already manage processing state, validation errors, and remembered form data. A Pinia store is rarely the right place for a single form.
When Pinia makes sense
Pinia is Vue’s store library. It provides shared state, actions, getters, plugins, devtools integration, and strong TypeScript support.
Use Pinia when several components need to coordinate around client-owned state.
Good examples include:
- A complex multi-panel editor
- A shopping cart that changes before checkout
- A client-side command palette
- A dashboard with several synchronized filters
- WebSocket-driven activity
- Optimistic UI with rollback behavior
- A persistent client preference used across layouts
A small Pinia store might manage a cart:
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
interface CartItem {
id: number
name: string
quantity: number
}
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const count = computed(() => {
return items.value.reduce((total, item) => total + item.quantity, 0)
})
function add(item: CartItem) {
const existing = items.value.find((entry) => entry.id === item.id)
if (existing) {
existing.quantity += item.quantity
return
}
items.value.push(item)
}
function remove(id: number) {
items.value = items.value.filter((item) => item.id !== id)
}
return {
items,
count,
add,
remove,
}
})
The cart is client-owned until checkout. Laravel still validates the final prices, stock levels, and authorization. Pinia only manages the user’s current interaction.
Pinia for real-time and optimistic interfaces
Real-time interfaces often need a place to collect events and expose derived state.
Laravel can broadcast events through Reverb and Echo. For example, a project dashboard might receive task updates from other team members.
A Vue component can listen for an event:
<script setup lang="ts">
import { useEcho } from '@laravel/echo-vue'
import { useTasksStore } from '@/stores/tasks'
const tasks = useTasksStore()
useEcho('projects.42', 'TaskUpdated', (event) => {
tasks.replace(event.task)
})
</script>
The store becomes useful when several parts of the interface need the same live data. A task list, activity panel, and project summary can all react to one update.
The server remains authoritative. A broadcast event updates the client quickly, but a later Inertia visit can reconcile the interface with Laravel.
Pinia also fits optimistic UI. A user can mark a task complete immediately. The store records the previous value. If Laravel rejects the request, the store restores it.
That flow should be deliberate:
- Save the previous client state.
- Apply the optimistic change.
- Submit the request.
- Accept the server response.
- Roll back on failure.
Optimistic UI belongs in a store when multiple components must reflect the temporary state. A local ref is enough when only one component displays it.

Avoid duplicating server state
The most common state mistake is copying every Inertia prop into Pinia.
For example, this creates two versions of the same data:
const page = usePage<{ projects: Project[] }>()
const projectsStore = useProjectsStore()
projectsStore.setProjects(page.props.projects)
Now the application must answer difficult questions:
- Which list is current?
- What happens after an Inertia visit?
- Which one receives a WebSocket update?
- When should the store be reset?
- How does pagination stay synchronized?
- What happens after a failed mutation?
Prefer reading server state directly from page props.
Use Pinia for a client-owned projection when the application needs complex interaction. If the store contains server data, define a clear synchronization boundary. Update it from a response, an event, or an explicit reload.
Do not use a store to avoid passing one prop through two small components. Prop drilling can be simpler than introducing global state.
A practical real-world example
Consider an order fulfillment dashboard.
Laravel owns the order, shipment status, customer details, permissions, and audit history. The controller returns the current order and its activity as page props.
The layout reads auth.user and flash through usePage(). The order page reads its own order prop. A local Vue ref controls the open state of the shipment modal.
Pinia becomes useful for the live activity panel. Laravel broadcasts ShipmentStatusUpdated through Reverb. Echo receives the event. The store updates the activity feed and exposes a computed count of unresolved issues.
When an operator changes the shipment status, the interface can update optimistically. Laravel still validates the transition. A successful response confirms the new status. A failure restores the previous value and displays a flash message.
Each layer has one job:
- Laravel: decides what is true.
- Inertia: delivers the current page state.
- Vue: manages local interaction.
- Pinia: coordinates complex client behavior.
A simple decision rule
Before adding Pinia, ask where the state belongs.
- Does Laravel persist it? Return it through Inertia.
- Is it needed across many pages? Share a small, namespaced prop.
- Is it specific to one page? Use page props.
- Is it temporary UI state? Use Vue.
- Does it coordinate complex client behavior? Consider Pinia.
- Does it change through WebSockets or optimistic updates? Pinia may provide a useful boundary.
This layered approach works well for teams building everything from small products to enterprise dashboards. It uses Laravel’s strengths as a PHP web framework, keeps Vue focused on the interface, and leaves Pinia for the cases that need it.
The same separation also helps when you later build a REST API with PHP. Your backend remains the authority. Your client state remains a deliberate projection.
Choose the smallest state layer that solves the problem. Your application will stay easier to test, explain, and extend.