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.
In a typical Laravel + Inertia setup, you might do something like this:
1// Controller2public 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.
First, install Laravel Data and the TypeScript Transformer:
1composer require spatie/laravel-data2composer require spatie/laravel-typescript-transformer --dev
Publish the config:
1php artisan vendor:publish --provider="Spatie\LaravelTypeScriptTransformer\TypeScriptTransformerServiceProvider"
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 Data10{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.
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.
Manually running php artisan typescript:transform gets old fast. Let's automate it.
Add to your composer.json:
1{2 "scripts": {3 "transform-types": "php artisan typescript:transform"4 }5}
Now run:
1composer run transform-types
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 types15 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.
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 Data13{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.
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}
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 Data3{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 values10 }11}
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: number10}
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 <Pagination15 currentPage={leads.current_page}16 lastPage={leads.last_page}17 total={leads.total}18 />19 </div>20 )21}
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.ts2import { 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.
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 <input26 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 Lead36 </button>37 </form>38 )39}
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 frontend10 public ?string $password_hash,11 ) {}12 13 public static function fromModel(User $user, bool $includeEmail = true): self14 {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}
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 install10 11 - name: Install Node dependencies12 run: npm install13 14 - name: Generate TypeScript types15 run: php artisan typescript:transform16 17 - name: Type check18 run: npm run type-check19 20 - name: Build21 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.
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 commit10 git add resources/js/types/generated.d.ts11fi
Make it executable:
1chmod +x .git/hooks/pre-commit
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.
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, // Optional10 ) {}11}
In TypeScript, this becomes:
1export type UserData = {2 id: number;3 name: string;4 email?: string; // Optional property5};
If types aren't generating correctly:
1# Verbose output2php artisan typescript:transform --verbose3 4# Check what's being scanned5php artisan typescript:transform --dry-run
Common issues:
Types not updating?
output_file path in configrm resources/js/types/generated.d.ts && php artisan typescript:transformEnum not transforming?
: string or : int)Collection types wrong?
/** @var DataCollection<YourData> */ annotation#[TypeScript] attributeType-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:
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.
Written by
Writing and maintaining @LaravelMagazine. Host of "The Laravel Magazine Podcast". Pronouns: vi/vim.
Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.