spatie/typescript-transformer
Convert PHP classes, enums, and DTOs into TypeScript types with spatie/typescript-transformer. It supports complex types, generics, and generated TypeScript functions, making frontend/backend type sharing simple.
In a Laravel app you will almost always pull in spatie/laravel-typescript-transformer rather than the core package directly — the Laravel wrapper adds the service provider, a publishable config file, and the php artisan typescript:transform command. Install it, publish the config, and set the output directory to wherever your frontend expects generated types (resources/js/types for a Vite/Inertia app, for example).
The first thing to read is the v3 documentation (spatie.be/docs/typescript-transformer/v3/introduction), not the v2 docs, tutorials, or Stack Overflow answers — v3 is a ground-up rewrite and almost every v2 code sample you'll find online is wrong. The second thing to read is the transformer list in the config file: that list is the behaviour of the package, and most "why is this type wrong?" questions are answered by looking at which transformers are registered and in what order.
Your first use case should be the smallest one that pays off: DTOs and enums that cross the PHP/TypeScript boundary.
#[TypeScript]
class UserData
{
public function __construct(
public int $id,
public string $name,
public ?string $address = null,
) {}
}
…becomes:
export type UserData = {
id: number;
name: string;
address: string | null;
}
Run php artisan typescript:transform, open the output file, and confirm the shape. That loop — annotate, run, inspect — is 90% of day-to-day usage.
Annotate the boundary, not the domain. Put #[TypeScript] on DTOs, form-request payloads, API resources, and enums — not on Eloquent models. Models are full of relations, $casts, accessors, and hidden attributes that you don't want in your frontend contract, and the generated type will be large and wrong. A thin Data/DTO layer that you control produces clean, intentional types.
Enums default to union types, and that's usually right. A backed enum becomes 'typescript' | 'php'. If your frontend prefers native TypeScript enums, the EnumTransformer supports that mode; pick one and be consistent, because mixing the two in one codebase gets confusing fast.
Use @template for generic payloads. Response envelopes and paginators are the classic case:
/**
* @template T
*/
class PaginatedResponse
{
/** @param array<T> $data */
public function __construct(
public int $page = 1,
public array $data = [],
) {}
}
generates type PaginatedResponse<T> = { page: number; data: T[] }. Without the @template tag you'll get unknown and lose all the value.
Choose a writer deliberately. ModuleWriter mirrors your PHP namespace tree into a directory of .ts modules — better for tree-shaking and for large apps. GlobalNamespaceWriter emits a single .d.ts with namespaced global types — simpler for a small app or a legacy frontend with no module imports. Don't switch later without a plan; every import path in your frontend changes.
Inject types the PHP side can't express with a TransformedProvider. This is how you get Collection<T>, LengthAwarePaginator<T>, or any shape that comes from a package:
class AddLaravelCollectionProvider implements TransformedProvider
{
public function provide(): array
{
return [new Transformed(
typeScriptNode: new TypeScriptAlias(
new TypeScriptGeneric(new TypeScriptIdentifier('Collection'), [new TypeScriptIdentifier('T')]),
new TypeScriptGeneric(new TypeScriptIdentifier('Array'), [new TypeScriptIdentifier('T')]),
),
reference: new ClassStringReference(Collection::class),
location: ['Illuminate', 'Support'],
)];
}
}
Custom transformers use shouldTransform, not a collector. In v3 the transformer decides both whether and how:
class DataObjectTransformer extends ClassTransformer
{
protected function shouldTransform(PhpClassNode $phpClassNode): bool
{
return $phpClassNode->implementsInterface(Data::class);
}
}
That's the right escape hatch when you can't — or don't want to — sprinkle #[TypeScript] on every class.
Post-process with visitors. The AST is mutable, so things like collapsing T | never or a single-member union are a few lines rather than a custom writer.
Put watch mode in your dev loop. It's in beta, but it removes the single most common source of "my frontend is stale" bugs. Pair it with a CI check that runs typescript:transform and fails on a dirty git diff so generated types can never drift from PHP.
DtoTransformer is gone, TypeReflectors are gone, inline types are gone, and TypeProcessors are replaced by ClassPropertyProcessor. If you're upgrading, read the v3 introduction end to end first — there is no upgrade guide, and a half-migrated config will fail in confusing ways..gitkeep) before your first run, especially in CI.unknown in the output is a type-resolution failure, not a transformer bug. It almost always means a missing or non-imported docblock type, a missing @template, or an annotation PHPStan's parser can't read. Fix the docblock rather than writing a transformer to patch it.@var resolves against the declaring class (fixed in 3.2.0), while class-level @property and constructor @param resolve against the current class. This matters when a child class lives in a different namespace; if you see unknown on inherited properties, check the parent's use statements.?string → string | null is the default; nullToOptional changes it to address?: string. Pick per project. Half the frontend codebase treating a field as nullable and the other half as optional is a real source of runtime bugs.ClassTransformer subclass → TransformedProvider → AST visitor. Reach for a visitor only when the problem is genuinely about the emitted TypeScript shape rather than about PHP semantics.How can I help you explore Laravel packages today?