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:
// Controller
public function show(User $user)
{
return Inertia::render('Users/Show', [
'user' => $user->toArray()
]);
}
// React component
interface Props {
user: {
id: number
name: string
email: string
}
}
export default function UserShow({ user }: Props) {
return <div>{user.name}</div>
}
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:
return Inertia::render('Users/Show', [
'user' => $user // Whoops, includes password hash, tokens, etc.
]);
Let's fix both problems at once.
Installing the Packages
First, install Laravel Data and the TypeScript Transformer:
composer require spatie/laravel-data
composer require spatie/laravel-typescript-transformer --dev
Publish the config:
php 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:
php artisan make:data UserData
This creates app/Data/UserData.php:
<?php
namespace App\Data;
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
#[TypeScript]
class UserData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $email,
public ?string $avatar,
) {}
}
The #[TypeScript] attribute tells the transformer to generate a TypeScript type for this class.
Now use it in your controller:
use App\Data\UserData;
public function show(User $user)
{
return Inertia::render('Users/Show', [
'user' => UserData::from($user)
]);
}
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:
return [
'output_file' => resource_path('js/types/generated.d.ts'),
'collectors' => [
Spatie\TypeScriptTransformer\Collectors\DefaultCollector::class,
],
];
Generate the types:
php artisan typescript:transform
This creates resources/js/types/generated.d.ts:
declare namespace App.Data {
export type UserData = {
id: number;
name: string;
email: string;
avatar: string | null;
};
}
Now use it in your React component:
interface Props {
user: App.Data.UserData
}
export default function UserShow({ user }: Props) {
return (
<div>
<h1>{user.name}</h1>
{user.avatar && <img src={user.avatar} alt={user.name} />}
</div>
)
}
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:
{
"scripts": {
"transform-types": "php artisan typescript:transform"
}
}
Now run:
composer run transform-types
Option 2: Watch for Changes with Vite
Install the watch plugin:
npm install -D vite-plugin-watch
Update vite.config.ts:
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import react from '@vitejs/plugin-react'
import { watch } from 'vite-plugin-watch'
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.tsx'],
refresh: true,
}),
react(),
// Watch Data objects and regenerate types
watch({
pattern: 'app/Data/**/*.php',
command: 'php artisan typescript:transform',
}),
],
})
Now whenever you change a Data object, types regenerate automatically whilst npm run dev is running.
Real-World Examples
Nested Data Objects
#[TypeScript]
class CompanyData extends Data
{
public function __construct(
public int $id,
public string $name,
public ?string $logo,
) {}
}
#[TypeScript]
class UserData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $email,
public ?CompanyData $company,
) {}
}
return Inertia::render('Users/Show', [
'user' => UserData::from($user)
]);
Generated TypeScript:
declare namespace App.Data {
export type CompanyData = {
id: number;
name: string;
logo: string | null;
};
export type UserData = {
id: number;
name: string;
email: string;
company: App.Data.CompanyData | null;
};
}
Full nested type safety. If CompanyData changes, TypeScript knows everywhere UserData is affected.
Collections
use Spatie\LaravelData\DataCollection;
#[TypeScript]
class TeamData extends Data
{
public function __construct(
public int $id,
public string $name,
/** @var DataCollection<UserData> */
public DataCollection $members,
) {}
}
$team = Team::with('members')->find(1);
return Inertia::render('Teams/Show', [
'team' => TeamData::from($team)
]);
Generated TypeScript:
declare namespace App.Data {
export type TeamData = {
id: number;
name: string;
members: Array<App.Data.UserData>;
};
}
The DataCollection becomes an array in TypeScript. Perfect for .map():
interface Props {
team: App.Data.TeamData
}
export default function TeamShow({ team }: Props) {
return (
<div>
<h1>{team.name}</h1>
<ul>
{team.members.map(member => (
<li key={member.id}>{member.name}</li>
))}
</ul>
</div>
)
}
Enums
TypeScript enums work great with Laravel enums:
<?php
namespace App\Enums;
enum UserRole: string
{
case Admin = 'admin';
case User = 'user';
case Guest = 'guest';
}
#[TypeScript]
class UserData extends Data
{
public function __construct(
public int $id,
public string $name,
public UserRole $role,
) {}
}
Generated TypeScript:
declare namespace App.Enums {
export type UserRole = 'admin' | 'user' | 'guest';
}
declare namespace App.Data {
export type UserData = {
id: number;
name: string;
role: App.Enums.UserRole;
};
}
Now TypeScript knows exactly which values are valid:
function getRoleBadgeColour(role: App.Enums.UserRole) {
switch (role) {
case 'admin':
return 'red'
case 'user':
return 'blue'
case 'guest':
return 'grey'
// TypeScript knows these are all possible values
}
}
Pagination
The new Laravel starter kits use Paginator types. Here's how to type them properly:
#[TypeScript]
class LeadData extends Data
{
public function __construct(
public int $id,
public string $company_name,
public string $email,
public int $score,
) {}
}
public function index()
{
$leads = Lead::latest()->paginate(20);
return Inertia::render('Leads/Index', [
'leads' => LeadData::collect($leads->items(), PaginatedDataCollection::class)
->withPaginationInformation($leads)
]);
}
Or simpler, create a Paginator type:
// resources/js/types/index.d.ts
export interface Paginator<T> {
data: T[]
current_page: number
last_page: number
per_page: number
total: number
from: number
to: number
}
import { Paginator } from '@/types'
interface Props {
leads: Paginator<App.Data.LeadData>
}
export default function LeadsIndex({ leads }: Props) {
return (
<div>
{leads.data.map(lead => (
<LeadCard key={lead.id} lead={lead} />
))}
<Pagination
currentPage={leads.current_page}
lastPage={leads.last_page}
total={leads.total}
/>
</div>
)
}
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:
// resources/js/types/index.d.ts
export interface SharedProps {
auth: {
user: App.Data.UserData | null
}
flash: {
success?: string
error?: string
}
}
Then create a typed usePage hook:
// resources/js/hooks/usePage.ts
import { usePage as useInertiaPage } from '@inertiajs/react'
import { SharedProps } from '@/types'
export function usePage<T = {}>() {
return useInertiaPage<SharedProps & T>()
}
Use it in any component:
import { usePage } from '@/hooks/usePage'
export default function Navigation() {
const { auth } = usePage().props
return (
<nav>
{auth.user ? (
<div>Welcome, {auth.user.name}</div>
) : (
<Link href="/login">Login</Link>
)}
</nav>
)
}
Full type safety for your global shared data.
Working with Forms
Data objects work brilliantly with forms:
#[TypeScript]
class CreateLeadData extends Data
{
public function __construct(
public string $company_name,
public string $email,
public string $website,
public ?string $phone,
) {}
}
use App\Data\CreateLeadData;
public function store(CreateLeadData $data)
{
$lead = Lead::create($data->toArray());
return redirect()->route('leads.show', $lead);
}
Laravel Data validates the request automatically based on your Data object.
On the frontend:
import { useForm } from '@inertiajs/react'
type CreateLeadForm = {
company_name: string
email: string
website: string
phone: string
}
export default function CreateLead() {
const { data, setData, post, processing, errors } = useForm<CreateLeadForm>({
company_name: '',
email: '',
website: '',
phone: '',
})
function submit(e: React.FormEvent) {
e.preventDefault()
post('/leads')
}
return (
<form onSubmit={submit}>
<input
type="text"
value={data.company_name}
onChange={e => setData('company_name', e.target.value)}
/>
{errors.company_name && <div>{errors.company_name}</div>}
{/* ... other fields ... */}
<button type="submit" disabled={processing}>
Create Lead
</button>
</form>
)
}
Advanced: Conditional Fields
Sometimes you want different fields based on context:
#[TypeScript]
class UserData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $email,
public ?string $avatar,
#[Hidden] // Never sent to frontend
public ?string $password_hash,
) {}
public static function fromModel(User $user, bool $includeEmail = true): self
{
return new self(
id: $user->id,
name: $user->name,
email: $includeEmail ? $user->email : '***',
avatar: $user->avatar,
password_hash: null,
);
}
}
CI/CD Integration
Add type generation to your deployment pipeline:
# .github/workflows/ci.yml
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install PHP dependencies
run: composer install
- name: Install Node dependencies
run: npm install
- name: Generate TypeScript types
run: php artisan typescript:transform
- name: Type check
run: npm run type-check
- name: Build
run: npm run build
Add a type-check script to package.json:
{
"scripts": {
"type-check": "tsc --noEmit"
}
}
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:
#!/bin/sh
# .git/hooks/pre-commit
# Check if any Data objects changed
if git diff --cached --name-only | grep -q "app/Data/"; then
echo "Data objects changed, regenerating TypeScript types..."
php artisan typescript:transform
# Add generated types to commit
git add resources/js/types/generated.d.ts
fi
Make it executable:
chmod +x .git/hooks/pre-commit
Common Patterns
View Models
For complex pages, create a dedicated Data object:
#[TypeScript]
class DashboardData extends Data
{
public function __construct(
public UserData $user,
public StatsData $stats,
/** @var DataCollection<LeadData> */
public DataCollection $recentLeads,
) {}
}
public function dashboard()
{
return Inertia::render('Dashboard',
DashboardData::from([
'user' => Auth::user(),
'stats' => $this->getStats(),
'recentLeads' => Lead::latest()->limit(5)->get(),
])
);
}
Your controller stays clean, and TypeScript knows the entire page structure.
Optional Data
Use Optional for data that might not be included:
use Spatie\LaravelData\Optional;
#[TypeScript]
class UserData extends Data
{
public function __construct(
public int $id,
public string $name,
public string|Optional $email, // Optional
) {}
}
In TypeScript, this becomes:
export type UserData = {
id: number;
name: string;
email?: string; // Optional property
};
Debugging
If types aren't generating correctly:
# Verbose output
php artisan typescript:transform --verbose
# Check what's being scanned
php artisan typescript:transform --dry-run
Common issues:
Types not updating?
- Check the
output_filepath 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 (
: stringor: 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.