cd /news/developer-tools/keeping-laravel-and-typescript-in-sy… · home › topics › developer-tools › article
[ARTICLE · art-146728] src=freek.dev ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

★ 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.

by read3 min views2 publishedOct 7, 2026

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, 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.

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.

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.

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 and 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.

── more in #developer-tools 4 stories · sorted by recency
── more on @spatie 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/keeping-laravel-and-…] indexed:0 read:3min 2026-10-07 · —