A Laravel controller can return the right data and still leave your Vue component guessing.
That gap appears when a property is renamed, a nullable value becomes required, or a backend query changes shape. The page may compile, then fail only after a user reaches the affected route.
TypeScript closes that gap across your Laravel, Inertia, and Vue layers. You define the contract once, apply it to page props and shared data, then carry it into forms, polling, and deferred responses.
This approach works well for teams using Laravel as a productive PHP web framework with a modern Vue frontend. It also gives your PHP developer tools a clear boundary with the browser.

Start with the response contract
Consider a controller that renders a project index page:
use Inertia\Inertia;
use Inertia\Response;
public function index(): Response
{
return Inertia::render('Projects/Index', [
'projects' => Project::query()
->with('owner:id,name')
->latest()
->get()
->map(fn (Project $project) => [
'id' => $project->id,
'name' => $project->name,
'status' => $project->status,
'owner' => [
'name' => $project->owner->name,
],
]),
]);
}
The controller returns a predictable structure. Each project has an ID, name, status, and owner name.
Now represent that structure in TypeScript:
// resources/js/types/projects.ts
export interface ProjectSummary {
id: number
name: string
status: 'active' | 'archived'
owner: {
name: string
}
}
export interface PageProps {
projects: ProjectSummary[]
}
This interface is the contract between ProjectsController and Projects/Index.vue.
It does not inspect PHP automatically. TypeScript cannot see the result of an Eloquent query. The interface documents the response and lets your frontend compiler check how the data is used.
Type the Vue page at its boundary
Use <script setup lang="ts"> and pass the interface to defineProps:
<script setup lang="ts">
import type { PageProps } from '@/types/projects'
const props = defineProps<PageProps>()
</script>
<template>
<section>
<h1>Projects</h1>
<article v-for="project in props.projects" :key="project.id">
<h2>{{ project.name }}</h2>
<p>{{ project.owner.name }}</p>
<span>{{ project.status }}</span>
</article>
</section>
</template>
TypeScript now knows that projects is an array. It also knows that status accepts only active or archived.
A typo such as project.owner.fullName fails during development. So does an invalid status comparison:
if (project.status === 'pending') {
// TypeScript reports an error.
}
That feedback matters as the application grows. A page component should not need to rediscover the shape of every Laravel response.
Type page props with usePage
defineProps is ideal when the page component reads its own props directly. usePage is useful when nested components or composables need access to the current Inertia page.
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3'
import type { PageProps } from '@/types/projects'
const page = usePage<PageProps>()
const projects = page.props.projects
</script>
Inertia merges your page-specific generic with globally configured shared props. That means page.props.projects and shared values such as page.props.auth can both receive autocomplete and type checking.
The official Inertia TypeScript documentation supports this pattern through generics and declaration merging.
Define shared data once
Most applications share a small set of values with every page. Common examples include the authenticated user, application name, permissions, and flash messages.
Laravel commonly provides this data through HandleInertiaRequests:
public function share(Request $request): array
{
return [
'appName' => config('app.name'),
'auth' => [
'user' => $request->user()?->only([
'id',
'name',
'email',
]),
],
'flash' => [
'success' => fn () => $request->session()->get('success'),
],
];
}
Create a declaration file for the shared contract:
// resources/js/types/inertia.d.ts
import '@inertiajs/core'
export interface SharedPageProps {
appName: string
auth: {
user: {
id: number
name: string
email: string
} | null
}
flash: {
success?: string
}
}
declare module '@inertiajs/core' {
interface InertiaConfig {
sharedPageProps: SharedPageProps
}
}
The import is important. It makes this file a module augmentation instead of replacing the original Inertia module.
Your tsconfig.json must also include the declaration file:
{
"include": [
"resources/js/**/*.ts",
"resources/js/**/*.vue",
"resources/js/**/*.d.ts"
]
}
You can now use shared data without repeating its shape in every page:
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3'
const page = usePage()
const userName = page.props.auth.user?.name ?? 'Guest'
</script>
<template>
<p>Welcome, {{ userName }}</p>
</template>
This follows the same principle as Laravel’s shared data documentation: define global page data centrally, then consume it consistently.
Keep deferred props explicit
Deferred props are useful when a page can render before an expensive query finishes. For example, a project page might load its main list immediately and defer statistics.
public function index(): Response
{
return Inertia::render('Projects/Index', [
'projects' => $this->projects(),
'stats' => Inertia::defer(fn () => [
'active' => Project::where('status', 'active')->count(),
'archived' => Project::where('status', 'archived')->count(),
]),
]);
}
Model the initial loading state in TypeScript:
export interface ProjectStats {
active: number
archived: number
}
export type Deferred<T> = T | undefined
export interface PageProps {
projects: ProjectSummary[]
stats: Deferred<ProjectStats>
}
Then guard the value in the component:
<script setup lang="ts">
import type { PageProps } from '@/types/projects'
const props = defineProps<PageProps>()
</script>
<template>
<section>
<h2>Project statistics</h2>
<div v-if="props.stats">
<span>Active: {{ props.stats.active }}</span>
<span>Archived: {{ props.stats.archived }}</span>
</div>
<p v-else>Loading statistics…</p>
</section>
</template>
The important detail is not the alias itself. It is the explicit loading state. TypeScript forces the component to decide what happens before the deferred value arrives.
If your backend returns null instead of omitting the value, use T | null. Match the type to your actual Laravel response contract.

Type forms with useForm
Inertia’s form helper accepts a generic. Use it for the data sent back to Laravel:
import { useForm } from '@inertiajs/vue3'
interface InviteForm {
email: string
role: 'member' | 'admin'
}
const form = useForm<InviteForm>({
email: '',
role: 'member',
})
function inviteUser() {
form.post('/team/invitations')
}
The form fields now have autocomplete. Typos in field names become compiler errors, and form.errors.email is linked to the same field contract.
This is particularly useful when a form grows beyond a few inputs. Nested objects and arrays can be represented directly:
interface ProjectForm {
name: string
tags: string[]
settings: {
visibility: 'private' | 'team'
}
}
const form = useForm<ProjectForm>({
name: '',
tags: [],
settings: {
visibility: 'team',
},
})
The Inertia forms documentation covers the runtime behavior. The generic adds a compile-time layer on top.
Use usePoll with typed surrounding data
usePoll reloads page props at an interval. In current Inertia Vue APIs, it does not take a page-data generic directly. Your page props remain typed through defineProps or usePage.
<script setup lang="ts">
import { usePoll } from '@inertiajs/vue3'
import type { PageProps } from '@/types/projects'
const props = defineProps<PageProps>()
const { polling, start, stop } = usePoll(
5000,
{ only: ['stats'] },
{ autoStart: true },
)
</script>
<template>
<section>
<p v-if="polling">Refreshing statistics…</p>
<strong v-if="props.stats">
{{ props.stats.active }} active projects
</strong>
<button type="button" @click="stop">
Stop updates
</button>
<button type="button" @click="start">
Start updates
</button>
</section>
</template>
props.stats stays typed as ProjectStats | undefined. The poll configuration limits reloads to the stats prop, while the component still handles its deferred or initial state safely.
The returned polling value is a typed Vue ref. In templates, Vue unwraps it automatically.
See the Inertia polling documentation for interval and lifecycle options.
Treat the boundary as an engineering decision
TypeScript does not validate a PHP response at runtime. If the controller returns owner_name but the interface expects owner.name, the browser still receives the wrong shape.
That is why response shaping matters. Return deliberate arrays, resources, or DTOs from Laravel instead of exposing unpredictable model structures. Keep dates, nullable fields, enum values, and nested relationships explicit.
The same discipline helps if you later build a REST API with PHP. A stable response contract can serve an Inertia page, an API consumer, or a mobile client.
For larger applications, teams often generate TypeScript definitions from PHP data objects or request classes. That reduces manual duplication, but the design principle remains the same: make the server response intentional, then carry that shape into the frontend.

A practical workflow for growing apps
A reliable Laravel and Vue codebase can follow this sequence:
- Shape the response in the Laravel controller or resource.
- Create a named TypeScript interface for page-specific data.
- Define shared props through
InertiaConfig. - Model deferred values with
undefinedornull. - Pass generics to
usePageanduseForm. - Type the props surrounding
usePoll. - Run
vue-tscand your PHP test suite in CI.
This keeps the route, controller, Inertia response, and Vue component aligned as the application changes.
The payoff is not more interfaces for their own sake. It is a shorter path between a backend change and the developer who needs to update the frontend. In a full-stack PHP application, that feedback makes maintenance calmer and feature work more deliberate.
Type the boundary once. Then let Laravel, Inertia, Vue, and TypeScript keep the contract visible.