If you’ve been following my recent posts, you know I’ve been on a mission to eliminate “magic” from my Laravel applications. I replaced loose associative arrays with strict DTOs and swapped implicit Observers for explicit Services. My backend has become a true fortress of stability.
But recently, I realized there was still a huge hole in my armor.
I spent hours crafting perfectly typed UserData objects in PHP. Then I sent them to Inertia.js and… poof. As soon as those data crossed the bridge into React (or Vue), they turned into the Wild West. My frontend components treated props as any, or worse, I manually wrote TypeScript interfaces that were always out of sync with the backend.
That’s how I closed the final gap in the stack using Spatie Laravel Data combined with the Spatie TypeScript Transformer.
Manual synchronization is a nightmare
Let’s be honest: keeping frontend types in sync with backend models is a losing battle.
You rename a PHP property from is_active to status. You update the database. You update the DTO. But you forget to update the UserProps interface in the frontend types.ts file.
The compiler doesn’t complain. The build passes. You deploy. Then a user clicks a button and the screen goes white.
Uncaught TypeError: Cannot read properties of undefined.
We’ve accepted this fragility as “part of the job.” It doesn’t have to be.
Automating the contract
The solution is to let PHP code write TypeScript code. Since we’re already using spatie/laravel-data to define our data structures, we can leverage spatie/laravel-typescript-transformer to automatically generate TS definitions whenever our classes change.
This isn’t just a nice-to-have. It’s the difference between hoping the frontend works and knowing it does.
The “not-magic” in action
Here’s how the setup works. You install the package, and suddenly your DTOs become the single source of truth for the entire application, end to end.
Let’s look at our trusted UserData class from the previous post. With a simple configuration, we can instruct Laravel to transform this class into a TypeScript type automatically.
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 $role,
/** @var array<int, string> */
public array $permissions
) {}
}You run a simple command: php artisan typescript:transform.
And this file is generated automatically:
export type UserData = {
id: number;
name: string;
email: string;
role: string | null;
permissions: Array<string>;
}Look at that precision. It handled ?string as string | null. It understood the PHPDoc array. I didn’t write a single character of this.
Refactoring the frontend component
Now let’s look at a React component using Inertia. Before, I was guessing what lived inside user. Now I get full IDE support.
import React from 'react';
import { UserData } from '@/types/generated';
interface Props {
user: UserData;
}
export default function UserProfile({ user }: Props) {
return (
<div className="p-4 shadow">
<h1>{user.name}</h1>
{/* TypeScript error: property 'isAdmin' does not exist on type 'UserData'. */}
{user.isAdmin && <span className="badge">Admin</span>}
{/* Correct usage based on the DTO */}
{user.role === 'admin' && <span className="badge">Admin</span>}
</div>
);
}The compiler catches my mistake before I even save the file.
The “I broke everything” workflow
The real value of this approach shows up when you refactor.
Imagine you decide that permissions should no longer be an array of strings, but an array of objects. You update the PHP DTO. You run the transformer. Immediately, the terminal turns red. The React build fails. TypeScript points to every frontend file where you were mapping over permissions expecting strings.
You don’t hunt bugs. The compiler hands you a clear to-do list. That peace of mind is priceless.
Working with Enums
And it gets even better. This works perfectly with PHP Enums.
Now the frontend logic can’t accidentally check for a role that doesn’t exist. You effectively eradicate magic strings across your entire codebase — from the database column to the JSX condition.
#[TypeScript]
enum UserRole: string
{
case Admin = 'admin';
case Editor = 'editor';
case Customer = 'customer';
}Becomes:
export type UserRole = 'admin' | 'editor' | 'customer';One language to rule the world everything
We often talk about “Backend” and “Frontend” as if they were separate worlds. But in a Laravel + Inertia monolith, they share the same domain.
By combining spatie/laravel-data with the TypeScript transformer, you build that bridge. You stop being just a PHP developer or a React developer — you become an Application Architect.
Yes, setting this pipeline up takes about 30 minutes. But the hours you save debugging errors like “undefined is not an object” are immeasurable.
Stop writing interfaces by hand. Let the machine do the work.