November 21st, 2025

Type-safe data flow: Laravel to React with Inertia 2.0

Type-safe data flow: Laravel to React with Inertia 2.0

Stop me if you've heard this before: you change a property name in your Laravel model, push to production, and suddenly your React components are trying to access user.firstName when it's now user.first_name. TypeScript doesn't catch it because you've typed it as any. Your users see broken pages.

Or maybe this one: a backend dev changes an API resource structure. The frontend works fine locally because their database still has old data. Then it hits production and everything breaks.

These aren't edge cases. They're inevitable when your backend and frontend types live in separate worlds.

Here's the good news: with Laravel Data and TypeScript Transformer, you can connect those worlds. One source of truth. Types generated automatically from your PHP code. Change something in Laravel, and TypeScript knows immediately.

Let me show you how to set this up properly.

The Problem We're Solving

In a typical Laravel + Inertia setup, you might do something like this:

1// Controller
2public function show(User $user)
3{
4 return Inertia::render('Users/Show', [
5 'user' => $user->toArray()
6 ]);
7}
1// React component
2interface Props {
3 user: {
4 id: number
5 name: string
6 email: string
7 }
8}
9 
10export default function UserShow({ user }: Props) {
11 return <div>{user.name}</div>
12}

Looks fine, right? But there's no connection between the PHP and TypeScript types. If the backend changes, TypeScript has no idea.

Even worse, you might be exposing data you shouldn't:

1return Inertia::render('Users/Show', [
2 'user' => $user // Whoops, includes password hash, tokens, etc.
3]);

Let's fix both problems at once.

Installing the Packages

First, install Laravel Data and the TypeScript Transformer:

1composer require spatie/laravel-data
2composer require spatie/laravel-typescript-transformer --dev

Publish the config:

1php artisan vendor:publish --provider="Spatie\LaravelTypeScriptTransformer\TypeScriptTransformerServiceProvider"

Creating Data Objects

Data objects are like DTOs (Data Transfer Objects). They explicitly define what data you're passing around.

Create your first data object:

1php artisan make:data UserData

This creates app/Data/UserData.php:

1<?php
2 
3namespace App\Data;
4 
5use Spatie\LaravelData\Data;
6use Spatie\TypeScriptTransformer\Attributes\TypeScript;
7 
8#[TypeScript]
9class UserData extends Data
10{
11 public function __construct(
12 public int $id,
13 public string $name,
14 public string $email,
15 public ?string $avatar,
16 ) {}
17}

The #[TypeScript] attribute tells the transformer to generate a TypeScript type for this class.

Now use it in your controller:

1use App\Data\UserData;
2 
3public function show(User $user)
4{
5 return Inertia::render('Users/Show', [
6 'user' => UserData::from($user)
7 ]);
8}

The from() method automatically maps your Eloquent model to the Data object. Only the properties you've defined get passed through. No more leaked passwords or tokens.

Generating TypeScript Types

Configure where the types should be generated. In config/typescript-transformer.php:

1return [
2 'output_file' => resource_path('js/types/generated.d.ts'),
3 
4 'collectors' => [
5 Spatie\TypeScriptTransformer\Collectors\DefaultCollector::class,
6 ],
7];

Generate the types:

1php artisan typescript:transform

This creates resources/js/types/generated.d.ts:

1declare namespace App.Data {
2 export type UserData = {
3 id: number;
4 name: string;
5 email: string;
6 avatar: string | null;
7 };
8}

Now use it in your React component:

1interface Props {
2 user: App.Data.UserData
3}
4 
5export default function UserShow({ user }: Props) {
6 return (
7 <div>
8 <h1>{user.name}</h1>
9 {user.avatar && <img src={user.avatar} alt={user.name} />}
10 </div>
11 )
12}

Full type safety from Laravel to React. Change avatar to profile_picture in your Data object, run the transform command, and TypeScript immediately tells you everywhere it's broken.

Automatic Type Generation

Manually running php artisan typescript:transform gets old fast. Let's automate it.

Option 1: Composer Script

Add to your composer.json:

1{
2 "scripts": {
3 "transform-types": "php artisan typescript:transform"
4 }
5}

Now run:

1composer run transform-types

Option 2: Watch for Changes with Vite

Install the watch plugin:

1npm install -D vite-plugin-watch

Update vite.config.ts:

1import { defineConfig } from 'vite'
2import laravel from 'laravel-vite-plugin'
3import react from '@vitejs/plugin-react'
4import { watch } from 'vite-plugin-watch'
5 
6export default defineConfig({
7 plugins: [
8 laravel({
9 input: ['resources/css/app.css', 'resources/js/app.tsx'],
10 refresh: true,
11 }),
12 react(),
13 
14 // Watch Data objects and regenerate types
15 watch({
16 pattern: 'app/Data/**/*.php',
17 command: 'php artisan typescript:transform',
18 }),
19 ],
20})

Now whenever you change a Data object, types regenerate automatically whilst npm run dev is running.

Real-World Examples

Nested Data Objects

1#[TypeScript]
2class CompanyData extends Data
3{
4 public function __construct(
5 public int $id,
6 public string $name,
7 public ?string $logo,
8 ) {}
9}
10 
11#[TypeScript]
12class UserData extends Data
13{
14 public function __construct(
15 public int $id,
16 public string $name,
17 public string $email,
18 public ?CompanyData $company,
19 ) {}
20}
1return Inertia::render('Users/Show', [
2 'user' => UserData::from($user)
3]);

Generated TypeScript:

1declare namespace App.Data {
2 export type CompanyData = {
3 id: number;
4 name: string;
5 logo: string | null;
6 };
7 
8 export type UserData = {
9 id: number;
10 name: string;
11 email: string;
12 company: App.Data.CompanyData | null;
13 };
14}

Full nested type safety. If CompanyData changes, TypeScript knows everywhere UserData is affected.

Collections

1use Spatie\LaravelData\DataCollection;
2 
3#[TypeScript]
4class TeamData extends Data
5{
6 public function __construct(
7 public int $id,
8 public string $name,
9 /** @var DataCollection<UserData> */
10 public DataCollection $members,
11 ) {}
12}
1$team = Team::with('members')->find(1);
2 
3return Inertia::render('Teams/Show', [
4 'team' => TeamData::from($team)
5]);

Generated TypeScript:

1declare namespace App.Data {
2 export type TeamData = {
3 id: number;
4 name: string;
5 members: Array<App.Data.UserData>;
6 };
7}

The DataCollection becomes an array in TypeScript. Perfect for .map():

1interface Props {
2 team: App.Data.TeamData
3}
4 
5export default function TeamShow({ team }: Props) {
6 return (
7 <div>
8 <h1>{team.name}</h1>
9 <ul>
10 {team.members.map(member => (
11 <li key={member.id}>{member.name}</li>
12 ))}
13 </ul>
14 </div>
15 )
16}

Enums

TypeScript enums work great with Laravel enums:

1<?php
2 
3namespace App\Enums;
4 
5enum UserRole: string
6{
7 case Admin = 'admin';
8 case User = 'user';
9 case Guest = 'guest';
10}
1#[TypeScript]
2class UserData extends Data
3{
4 public function __construct(
5 public int $id,
6 public string $name,
7 public UserRole $role,
8 ) {}
9}

Generated TypeScript:

1declare namespace App.Enums {
2 export type UserRole = 'admin' | 'user' | 'guest';
3}
4 
5declare namespace App.Data {
6 export type UserData = {
7 id: number;
8 name: string;
9 role: App.Enums.UserRole;
10 };
11}

Now TypeScript knows exactly which values are valid:

1function getRoleBadgeColour(role: App.Enums.UserRole) {
2 switch (role) {
3 case 'admin':
4 return 'red'
5 case 'user':
6 return 'blue'
7 case 'guest':
8 return 'grey'
9 // TypeScript knows these are all possible values
10 }
11}

Pagination

The new Laravel starter kits use Paginator types. Here's how to type them properly:

1#[TypeScript]
2class LeadData extends Data
3{
4 public function __construct(
5 public int $id,
6 public string $company_name,
7 public string $email,
8 public int $score,
9 ) {}
10}
1public function index()
2{
3 $leads = Lead::latest()->paginate(20);
4 
5 return Inertia::render('Leads/Index', [
6 'leads' => LeadData::collect($leads->items(), PaginatedDataCollection::class)
7 ->withPaginationInformation($leads)
8 ]);
9}

Or simpler, create a Paginator type:

1// resources/js/types/index.d.ts
2export interface Paginator<T> {
3 data: T[]
4 current_page: number
5 last_page: number
6 per_page: number
7 total: number
8 from: number
9 to: number
10}
1import { Paginator } from '@/types'
2 
3interface Props {
4 leads: Paginator<App.Data.LeadData>
5}
6 
7export default function LeadsIndex({ leads }: Props) {
8 return (
9 <div>
10 {leads.data.map(lead => (
11 <LeadCard key={lead.id} lead={lead} />
12 ))}
13 
14 <Pagination
15 currentPage={leads.current_page}
16 lastPage={leads.last_page}
17 total={leads.total}
18 />
19 </div>
20 )
21}

Shared Data (Global Props)

Every Inertia app has shared data—stuff that's available on every request. Usually your authenticated user.

Create a type for it:

1// resources/js/types/index.d.ts
2export interface SharedProps {
3 auth: {
4 user: App.Data.UserData | null
5 }
6 flash: {
7 success?: string
8 error?: string
9 }
10}

Then create a typed usePage hook:

1// resources/js/hooks/usePage.ts
2import { usePage as useInertiaPage } from '@inertiajs/react'
3import { SharedProps } from '@/types'
4 
5export function usePage<T = {}>() {
6 return useInertiaPage<SharedProps & T>()
7}

Use it in any component:

1import { usePage } from '@/hooks/usePage'
2 
3export default function Navigation() {
4 const { auth } = usePage().props
5 
6 return (
7 <nav>
8 {auth.user ? (
9 <div>Welcome, {auth.user.name}</div>
10 ) : (
11 <Link href="/login">Login</Link>
12 )}
13 </nav>
14 )
15}

Full type safety for your global shared data.

Working with Forms

Data objects work brilliantly with forms:

1#[TypeScript]
2class CreateLeadData extends Data
3{
4 public function __construct(
5 public string $company_name,
6 public string $email,
7 public string $website,
8 public ?string $phone,
9 ) {}
10}
1use App\Data\CreateLeadData;
2 
3public function store(CreateLeadData $data)
4{
5 $lead = Lead::create($data->toArray());
6 
7 return redirect()->route('leads.show', $lead);
8}

Laravel Data validates the request automatically based on your Data object.

On the frontend:

1import { useForm } from '@inertiajs/react'
2 
3type CreateLeadForm = {
4 company_name: string
5 email: string
6 website: string
7 phone: string
8}
9 
10export default function CreateLead() {
11 const { data, setData, post, processing, errors } = useForm<CreateLeadForm>({
12 company_name: '',
13 email: '',
14 website: '',
15 phone: '',
16 })
17 
18 function submit(e: React.FormEvent) {
19 e.preventDefault()
20 post('/leads')
21 }
22 
23 return (
24 <form onSubmit={submit}>
25 <input
26 type="text"
27 value={data.company_name}
28 onChange={e => setData('company_name', e.target.value)}
29 />
30 {errors.company_name && <div>{errors.company_name}</div>}
31 
32 {/* ... other fields ... */}
33 
34 <button type="submit" disabled={processing}>
35 Create Lead
36 </button>
37 </form>
38 )
39}

Advanced: Conditional Fields

Sometimes you want different fields based on context:

1#[TypeScript]
2class UserData extends Data
3{
4 public function __construct(
5 public int $id,
6 public string $name,
7 public string $email,
8 public ?string $avatar,
9 #[Hidden] // Never sent to frontend
10 public ?string $password_hash,
11 ) {}
12 
13 public static function fromModel(User $user, bool $includeEmail = true): self
14 {
15 return new self(
16 id: $user->id,
17 name: $user->name,
18 email: $includeEmail ? $user->email : '***',
19 avatar: $user->avatar,
20 password_hash: null,
21 );
22 }
23}

CI/CD Integration

Add type generation to your deployment pipeline:

1# .github/workflows/ci.yml
2jobs:
3 ci:
4 runs-on: ubuntu-latest
5 steps:
6 - uses: actions/checkout@v3
7 
8 - name: Install PHP dependencies
9 run: composer install
10 
11 - name: Install Node dependencies
12 run: npm install
13 
14 - name: Generate TypeScript types
15 run: php artisan typescript:transform
16 
17 - name: Type check
18 run: npm run type-check
19 
20 - name: Build
21 run: npm run build

Add a type-check script to package.json:

1{
2 "scripts": {
3 "type-check": "tsc --noEmit"
4 }
5}

Now your CI fails if types don't match. You'll never deploy broken types again.

Git Pre-Commit Hook

Make sure types are always up to date:

1#!/bin/sh
2# .git/hooks/pre-commit
3 
4# Check if any Data objects changed
5if git diff --cached --name-only | grep -q "app/Data/"; then
6 echo "Data objects changed, regenerating TypeScript types..."
7 php artisan typescript:transform
8 
9 # Add generated types to commit
10 git add resources/js/types/generated.d.ts
11fi

Make it executable:

1chmod +x .git/hooks/pre-commit

Common Patterns

View Models

For complex pages, create a dedicated Data object:

1#[TypeScript]
2class DashboardData extends Data
3{
4 public function __construct(
5 public UserData $user,
6 public StatsData $stats,
7 /** @var DataCollection<LeadData> */
8 public DataCollection $recentLeads,
9 ) {}
10}
1public function dashboard()
2{
3 return Inertia::render('Dashboard',
4 DashboardData::from([
5 'user' => Auth::user(),
6 'stats' => $this->getStats(),
7 'recentLeads' => Lead::latest()->limit(5)->get(),
8 ])
9 );
10}

Your controller stays clean, and TypeScript knows the entire page structure.

Optional Data

Use Optional for data that might not be included:

1use Spatie\LaravelData\Optional;
2 
3#[TypeScript]
4class UserData extends Data
5{
6 public function __construct(
7 public int $id,
8 public string $name,
9 public string|Optional $email, // Optional
10 ) {}
11}

In TypeScript, this becomes:

1export type UserData = {
2 id: number;
3 name: string;
4 email?: string; // Optional property
5};

Debugging

If types aren't generating correctly:

1# Verbose output
2php artisan typescript:transform --verbose
3 
4# Check what's being scanned
5php artisan typescript:transform --dry-run

Common issues:

Types not updating?

  • Check the output_file path in config
  • Make sure you're running the transform command
  • Clear and rebuild: rm resources/js/types/generated.d.ts && php artisan typescript:transform

Enum not transforming?

  • Backed enums work (: string or : int)
  • Pure enums don't transform well, use backed enums

Collection types wrong?

  • Use /** @var DataCollection<YourData> */ annotation
  • Make sure the Data class has #[TypeScript] attribute

The Bottom Line

Type-safe data flow from Laravel to React isn't optional anymore. Not if you want to move fast without breaking things.

With Laravel Data and TypeScript Transformer, you get:

  • One source of truth (your PHP code)
  • Automatic type generation
  • Compile-time safety
  • Better refactoring
  • Fewer bugs in production

The setup takes 30 minutes. The time saved debugging missing properties and typos? Immeasurable.

Start with your User model. Create a UserData object. Generate the types. Use them in one component. See how much better it feels.

Then do it for everything else.


In Leadsprout, every single Inertia response uses a Data object. We have zero runtime errors from wrong property names. When we refactor, Typescript catches every place that needs updating. It's genuinely game-changing.

Statamic Ninja

Comments

Marian Pop

PHP / Laravel Developer. Writing and maintaining @LaravelMagazine. Host of "The Laravel Magazine Podcast". Pronouns: vi/vim.

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.