Laravel Magazine

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

Eric Van Johnson · Laravel Inertia React
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_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.

Stay Updated

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.