DTOs & TypeScript

DTOs are the strict contract between Laravel and Inertia, and the single source of truth for every frontend type. A PHP DTO marked #[TypeScript] is both the shape sent to the page and the origin of the matching TypeScript type. This is the most important best practice in the stack.

Core rules

  • Every Inertia page gets its data from a DTO — never a raw Eloquent model.
  • Include only the fields the UI actually needs; don't mirror the whole table "just in case".
  • The DTO is the contract: if changing it breaks the frontend build, that's the type-checker doing its job.

Wiring the transformer

Configure the transformer in a service provider (no config file needed with the v3 factory API):

// app/Providers/TypeScriptTransformerServiceProvider.php
namespace App\Providers;

use Spatie\LaravelTypeScriptTransformer\TypeScriptTransformerServiceProvider as BaseProvider;
use Spatie\TypeScriptTransformer\TypeScriptTransformerConfigFactory;
use Spatie\TypeScriptTransformer\Transformers\{AttributedClassTransformer, EnumTransformer};
use Spatie\TypeScriptTransformer\Writers\GlobalNamespaceWriter;
use Spatie\TypeScriptTransformer\Formatters\PrettierFormatter;

class TypeScriptTransformerServiceProvider extends BaseProvider
{
    protected function configure(TypeScriptTransformerConfigFactory $config): void
    {
        $config
            ->transformer(AttributedClassTransformer::class)
            ->transformer(EnumTransformer::class)
            ->transformDirectories(app_path('Data'), app_path('Enums'))
            ->outputDirectory(resource_path('js/types'))
            ->writer(new GlobalNamespaceWriter('generated.d.ts'))
            ->formatter(PrettierFormatter::class);
    }
}

Register it in bootstrap/providers.php. Then:

php artisan typescript:transform

…writes resources/js/types/generated.d.ts as a global ambient namespace — usable anywhere without importing:

declare namespace App {
  namespace Data {
    export type PostData = {
      id: number;
      title: string;
      status: App.Enums.PostStatus;
      publishedAt: string | null;
    };
  }
  namespace Enums {
    export type PostStatus = "draft" | "published" | "archived";
  }
}

Writing a DTO

use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;

#[TypeScript]
class PostData extends Data
{
    public function __construct(
        public int $id,
        public string $title,
        public PostStatus $status,          // PHP enum → TS string union
        public ?string $publishedAt,
        /** @var App\Data\TagData[] */
        public array $tags,                 // docblock drives array element type
    ) {}

    public static function fromModel(Post $post): self
    {
        return new self(
            id: $post->id,
            title: $post->title,
            status: $post->status,
            publishedAt: $post->published_at?->toIso8601String(),
            tags: TagData::collect($post->tags),
        );
    }
}
Rule Detail
#[TypeScript] Required, or it won't appear in generated.d.ts
Strict types int/string/bool/enums — avoid mixed
/** @var X[] */ The only way array element types survive into TS
fromModel() factory Central mapping point; keeps controllers thin
Nested DTOs Relations become their own DTO, never a raw model
*Data / *PageData naming *PageData for the top-level props of a page

Consuming it in React

Page DTOs are Responsable, so pass the object straight to Inertia::render:

// Controller — thin: validate, delegate, render a DTO
public function index(): Response
{
    return Inertia::render('Posts/Index', PostIndexPageData::from($this->posts->list()));
}
// resources/js/Pages/Posts/Index.tsx
import type { PageProps } from '@/types';

interface Props extends PageProps, App.Data.PostIndexPageData {}

export default function Index({ posts }: Props) {
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

Feature modules may re-export the generated types under local aliases, but must never redefine the shape by hand:

// resources/js/features/posts/types.ts
// Generated from PHP DTOs — run `php artisan typescript:transform` to regenerate.
export type Post = App.Data.PostData;

Danger — Never hand-write a TypeScript interface that duplicates a DTO. The moment the PHP and TS drift, you have two contracts and no source of truth. Regenerate instead.

Enforce the contract — the type-drift gate

typescript:transform is a manual command, which means generated.d.ts can silently go stale — the one hole in the coupling story. Close it twice:

Locally — regenerate automatically before dev and build via npm pre-hooks:

// package.json
{
  "scripts": {
    "predev": "php artisan typescript:transform",
    "prebuild": "php artisan typescript:transform",
    "dev": "vite",
    "build": "tsc && vite build"
  }
}

In CI — regenerate and fail the build if the committed file doesn't match:

# in the workflow, after composer/npm install
- name: Verify generated types are current
  run: |
    php artisan typescript:transform
    git diff --exit-code resources/js/types/generated.d.ts

A DTO change without a regenerated-and-committed generated.d.ts now fails the pipeline. The contract is enforced, not just documented.

The golden rule

If changing a DTO breaks the frontend → that's correct. A type error after a DTO change is the compiler forcing the UI to handle the new shape. Run php artisan typescript:transform, let tsc list the breakages, fix them.

Next