Laravel Daily's

Type-Safe Laravel + Vue + Inertia: From PHP DTOs to Typed Vue Props

hero image

Type safety works best when it covers the whole application.

A PHP type at the database boundary should not disappear inside a controller. It should reach the Inertia response, the Vue page, the form, and the route that submits it.

This is now practical with Laravel, Vue, Inertia 3.x, Laravel Wayfinder, and Spatie Laravel Data. Each tool covers a different part of the data flow.

The result is a Laravel application where backend changes surface quickly in frontend type checks.

Inertia 3.x: TypeScript at the page boundary

Inertia 3.x released in August 2026 with first-class TypeScript support. It adds stronger typing for page props, shared props, forms, router requests, layouts, and remembered state.

The most useful feature is global configuration through InertiaConfig augmentation.

Create a declaration file such as resources/js/types/inertia.d.ts:

import '@inertiajs/core'

declare module '@inertiajs/core' {
    interface InertiaConfig {
        sharedPageProps: {
            auth: {
                user: {
                    id: number
                    name: string
                    email: string
                } | null
            }
            appName: string
        }

        flashDataType: {
            toast?: {
                type: 'success' | 'error'
                message: string
            }
        }

        errorValueType: string[]
    }
}

The import is important. It turns the file into a module and enables declaration merging instead of replacing Inertia’s types.

Your tsconfig.json must include this file:

{
    "include": [
        "resources/**/*.ts",
        "resources/**/*.d.ts",
        "resources/**/*.vue"
    ]
}

Now shared props are typed wherever you use usePage().

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

const page = usePage()

const userName = page.props.auth.user?.name
const appName = page.props.appName
const toastMessage = page.props.flash.toast?.message
</script>

Page-specific props use a generic. Inertia merges them with the globally configured shared props.

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

type DashboardProps = {
    stats: {
        users: number
        revenue: number
    }
}

const page = usePage<DashboardProps>()

page.props.stats.users
page.props.auth.user?.email

Forms receive the same treatment.

import { useForm } from '@inertiajs/vue3'

type ProfileForm = {
    name: string
    email: string
    company: {
        name: string
    }
}

const form = useForm<ProfileForm>({
    name: '',
    email: '',
    company: {
        name: '',
    },
})

A misspelled field now fails during type checking. Nested objects and arrays remain typed as well.

Illustration of InertiaConfig, shared page props, usePage, and useForm connecting Laravel to Vue

Wayfinder: Typed routes from Laravel

Types are incomplete if the frontend still builds URLs by hand.

A renamed route parameter can break navigation without producing a TypeScript error. A changed HTTP method can create a runtime failure. Laravel Wayfinder closes that gap by generating typed TypeScript functions from your Laravel routes and controller actions.

Install it with Composer:

composer require laravel/wayfinder

Install the Vite plugin as well:

npm install -D @laravel/vite-plugin-wayfinder

Add the plugin to vite.config.ts:

import { defineConfig } from 'vite'
import { wayfinder } from '@laravel/vite-plugin-wayfinder'

export default defineConfig({
    plugins: [
        wayfinder(),
    ],
})

You can generate definitions manually:

php artisan wayfinder:generate

By default, Wayfinder writes generated routes and actions under resources/js. You can change the output path with --path.

The Vite plugin also regenerates definitions during the build and while the Vite development server runs. When you edit a route or controller, the generated client functions update with it.

Suppose Laravel has this route:

Route::get('/posts/{post}', [PostController::class, 'show'])
    ->name('posts.show');

Wayfinder can generate a function that understands the route parameter:

import { show } from '@/actions/App/Http/Controllers/PostController'

show(1)
show({ post: 1 })
show.url(1)

It can also detect custom binding keys. A route such as /posts/{post:slug} accepts a slug rather than an arbitrary URL string.

Wayfinder functions return the URL and HTTP method together:

show(1)
// {
//     url: '/posts/1',
//     method: 'get',
// }

That return value works directly with Inertia components.

<script setup lang="ts">
import { Link } from '@inertiajs/vue3'
import { show } from '@/actions/App/Http/Controllers/PostController'
</script>

<template>
    <Link :href="show(1)">
        View post
    </Link>
</template>

It also works with useForm:

import { useForm } from '@inertiajs/vue3'
import { update } from '@/actions/App/Http/Controllers/PostController'

const form = useForm({
    title: 'Updated title',
})

form.submit(update(1))

The route determines the URL and method. You do not duplicate either value in the Vue component.

Wayfinder is currently in beta, so check its changelog before upgrading. Its generated output should usually remain outside manually maintained source files.

Bright cartoony illustration of Laravel Wayfinder generating typed routes for Vue links and Inertia forms

Laravel Data: One PHP definition for many boundaries

Page props often begin as arrays. Arrays are flexible, but they hide the contract between the backend and frontend.

Spatie Laravel Data lets you describe that contract with a typed PHP data object. The same object can support validation, transformation, API responses, and TypeScript generation.

<?php

namespace App\Data;

use Spatie\LaravelData\Data;

class UserData extends Data
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
    ) {}
}

You can use the DTO in an Inertia response:

use App\Data\UserData;
use Inertia\Inertia;

return Inertia::render('Users/Show', [
    'user' => UserData::from($user),
]);

The data shape now has one primary definition. You do not need to maintain a PHP DTO, a resource shape, and a separate frontend interface by hand.

Spatie’s TypeScript Transformer can generate TypeScript definitions from these data classes. Add the LaravelDataTypeScriptTransformerExtension to your transformer configuration.

A generated interface may look like this:

export type UserData = {
    id: number
    name: string
    email: string
}

Import it into a Vue page:

<script setup lang="ts">
import { usePage } from '@inertiajs/vue3'
import type { UserData } from '@/types/generated'

type UserPageProps = {
    user: UserData
}

const page = usePage<UserPageProps>()
</script>

<template>
    <h1>{{ page.props.user.name }}</h1>
</template>

The transformer also understands Laravel Data’s lazy properties and Inertia deferred props.

This distinction matters:

  • A nullable PHP property becomes Type | null.
  • A lazy or deferred property becomes an optional TypeScript property.
  • A property can be both optional and nullable.

For example:

use Spatie\LaravelData\Data;
use Spatie\LaravelData\Lazy;

class DashboardData extends Data
{
    public function __construct(
        public string $title,
        public Lazy|AnalyticsData $analytics,
        public ?string $subtitle,
    ) {}
}

The generated type is conceptually:

export type DashboardData = {
    title: string
    analytics?: AnalyticsData
    subtitle: string | null
}

analytics may be absent because it is lazy. subtitle exists but may contain null. TypeScript keeps those cases separate.

Bright illustration of PHP Laravel Data DTOs transforming into TypeScript interfaces and optional deferred props

Deferred props and prefetching: Type the loading state

Inertia 3.x includes deferred props for data that does not need to block the first render.

return Inertia::render('Dashboard', [
    'summary' => DashboardSummaryData::from($summary),

    'analytics' => Inertia::defer(
        fn () => AnalyticsData::from($analytics)
    ),
]);

The deferred callback runs in a separate request after the initial page render. This keeps heavier queries away from the first response.

On the Vue side, use the Deferred component:

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

<template>
    <Deferred data="analytics">
        <template #fallback>
            <div>Loading analytics...</div>
        </template>

        <AnalyticsPanel />
    </Deferred>
</template>

The generated TypeScript type should mark analytics as optional. Your component must handle the period before the deferred request finishes.

Inertia 3.x also includes prefetching. A link can request page data when a user hovers, focuses, mounts, or clicks it.

<Link
    href="/reports"
    prefetch="hover"
>
    Reports
</Link>

Prefetching and deferred props solve different problems. Prefetching prepares a future page response. Deferred props delay part of the current page response. A prefetched page can still resolve deferred data after navigation.

A practical end-to-end pattern

Use the three tools at different boundaries.

First, define backend data with Laravel Data:

class ReportsPageData extends Data
{
    public function __construct(
        public ReportSummaryData $summary,
        public Lazy|ReportDetailsData $details,
    ) {}
}

Then return it through Inertia:

return Inertia::render('Reports/Index', [
    'reports' => ReportsPageData::from([
        'summary' => $summary,
        'details' => Lazy::inertiaDeferred(
            fn () => ReportDetailsData::from($details)
        ),
    ]),
]);

The TypeScript Transformer generates the frontend contract:

export type ReportsPageData = {
    summary: ReportSummaryData
    details?: ReportDetailsData
}

Augment Inertia’s shared types for global application data:

import '@inertiajs/core'
import type { UserData } from '@/types/generated'

declare module '@inertiajs/core' {
    interface InertiaConfig {
        sharedPageProps: {
            auth: {
                user: UserData | null
            }
        }
    }
}

Type the page and form in Vue:

<script setup lang="ts">
import { Deferred, Link, useForm, usePage } from '@inertiajs/vue3'
import { store } from '@/actions/App/Http/Controllers/ReportController'
import type { ReportsPageData } from '@/types/generated'

type ReportsProps = {
    reports: ReportsPageData
}

const page = usePage<ReportsProps>()

const form = useForm({
    name: '',
    filters: {
        status: 'open' as 'open' | 'closed',
    },
})

function createReport() {
    form.submit(store())
}
</script>

<template>
    <section>
        <h1>{{ page.props.reports.summary.title }}</h1>

        <Deferred data="reports.details">
            <template #fallback>
                <p>Loading report details...</p>
            </template>

            <ReportTable
                v-if="page.props.reports.details"
                :reports="page.props.reports.details"
            />
        </Deferred>

        <form @submit.prevent="createReport">
            <input v-model="form.name" name="name" />
            <p v-if="form.errors.name">{{ form.errors.name }}</p>

            <button type="submit">
                Create report
            </button>
        </form>
    </section>
</template>

The exact prop path may vary with your chosen Laravel Data structure. The important pattern stays the same:

PHP types
→ Laravel Data
→ Inertia props
→ InertiaConfig and usePage<T>
→ Vue components
→ useForm<T>
→ Wayfinder route actions

This approach also works when you need to build a REST API with PHP. The DTO can serve an Inertia page today and an API response tomorrow.

For a new project, Laravel’s Vue starter kit already includes Vue 3, TypeScript, Inertia 3, and shadcn-vue. You can then add Wayfinder and Laravel Data as the application grows.

Type safety does not remove the need for tests or backend validation. It gives those checks a stronger starting point. For PHP developers comparing PHP developer tools in a php web framework, the useful measure is simple: how many boundaries can share one clear contract?

With Laravel, those boundaries can now stay connected from a PHP DTO to a typed Vue prop.

If you are using this pattern in a Laravel and Vue application, we would love to hear what you generate automatically and what you keep explicit.

Previous
Content Generation in Laravel: From Prompt to Published Post with the AI SDK
Next
Laravel Boost 2.6.0: The New Testing Best Practices Skill and Smarter Agent Tooling