★ Keeping Laravel and TypeScript in sync with data objects Spatie's `spatie/laravel-typescript-transformer` package generates TypeScript definitions from PHP `Data` classes at build time, keeping Laravel backends and TypeScript frontends in sync for the company's There There helpdesk app, currently in private beta. The transformer auto-discovers annotated classes in `app/`, applies transformers for data classes, DTOs, enums and state machines, and writes output to `resources/js/types/generated.d.ts`, with `default_type_replacements` mapping every `Carbon` date to a plain `string`. Spatie wires the transform into Vite's `handleHotUpdate` so the file regenerates whenever a data class or enum changes, rather than running `php artisan typescript:transform` manually. At Spatie we build SaaS apps with Laravel on the backend and Inertia with React on the frontend. That split means two type systems have to agree about every payload that crosses between them. Laravel data going out, TypeScript picking it up. There There https://there-there.app , the helpdesk we're building, is built exactly like that. Tickets, messages, members, custom sidebar sections, all of it crosses the bridge. We'd go insane maintaining those types by hand. So we don't. There There is in private beta right now, and you can apply for early access at there-there.app https://there-there.app . Two Spatie packages make this painless. spatie/laravel-data shapes outgoing payloads into typed PHP classes, and spatie/laravel-typescript-transformer turns those classes into TypeScript definitions at build time. Let me show what that looks like in practice. A payload starts life as a Data class. Here's our MemberData , which we send down every time we render the member detail page. php use Spatie\LaravelData\Data; use Spatie\TypeScriptTransformer\Attributes\TypeScript; TypeScript class MemberData extends Data { public function construct public int $id, public string $ulid, public string $name, public string $email, public ?string $avatar url, public WorkspaceMemberRole $role, public string $joined at, public bool $restricted channel access = false, {} public static function fromPivotUser User $user : self { return new self id: $user- id, ulid: $user- ulid, name: $user- name, email: $user- email, avatar url: $user- getAvatarUrlWithFallback , role: WorkspaceMemberRole::from $user- pivot- role , joined at: $user- pivot- created at- toIso8601String , restricted channel access: bool $user- pivot- restricted channel access, ; } } The TypeScript attribute is the opt-in. Any PHP class that carries it is picked up by the transformer. The constructor properties become the TypeScript type, including the enum WorkspaceMemberRole , the nullable string, and the boolean with a default. The transformer itself is a regular Laravel package with a config file. Here's the relevant part of ours. js return 'auto discover types' = app path , , 'transformers' = SpatieStateTransformer::class, EnumTransformer::class, DataTypeScriptTransformer::class, DtoTransformer::class, , 'default type replacements' = Carbon\Carbon::class = 'string', , 'output file' = resource path 'js/types/generated.d.ts' , ; Three things worth knowing. auto discover types scans app/ for any annotated class. transformers handles data classes, DTOs, enums, and Spatie state machines. default type replacements lets you swap PHP types for their TypeScript equivalents, which is how every Carbon date becomes a plain string on the frontend. When the transformer runs, it writes resources/js/types/generated.d.ts . For our MemberData , the output looks like this. declare namespace App.Domain.Settings.Data { export type MemberData = { id: number; ulid: string; name: string; email: string; avatar url: string | null; role: App.Domain.Workspace.Enums.WorkspaceMemberRole; joined at: string; restricted channel access: boolean; }; } The namespace mirrors the PHP namespace. The property shapes line up with the constructor. Enums are cross-referenced to their own generated types, so renaming an enum case in PHP propagates to every TypeScript caller. You could regenerate this file manually with php artisan typescript:transform , but we wire it into Vite so it happens whenever a data class or enum changes. handleHotUpdate { file } { if isServe && file.endsWith '.php' && file.includes '/Data/' || file.includes '/Enums/' { generate ; } }, That snippet sits inside a small custom Vite plugin. When the dev server is running and a PHP file under Data/ or Enums/ changes, we shell out to php artisan typescript:transform and the frontend types update in place. No manual step, no stale definitions. TODO: screenshot of editor autocomplete on a MemberData object showing all the fields, or a TypeScript error firing after a backend rename that hasn't been consumed on the frontend yet. This setup has erased a whole category of papercut. We rename a PHP property, the TypeScript compiler lights up on every frontend caller. We add a field, it's available to autocomplete in seconds. The two sides stay honest. You can find the packages on GitHub: laravel-data https://github.com/spatie/laravel-data and laravel-typescript-transformer https://github.com/spatie/laravel-typescript-transformer . And if you'd like to try There There yourself, we're in private beta right now and you can apply for early access at there-there.app https://there-there.app .