brunoscode / laravel-ts-annotations
Write raw TypeScript types in PHP attributes and generate .ts files with an Artisan command.
Package info
github.com/BrunosCode/LaravelTsAnnotations
pkg:composer/brunoscode/laravel-ts-annotations
Requires
- php: ^8.2
- illuminate/console: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
This package is auto-updated.
Last update: 2026-06-16 13:00:24 UTC
README
Generate TypeScript types from PHP attributes and emit them to
.tsfiles with a single Artisan command — for Laravel apps with a typed frontend.
Keeping PHP and a typed frontend in sync usually means one of two compromises: infer TypeScript from PHP types (and lose union types, template literals, and generics), or route everything through a Swagger/OpenAPI intermediary (indirect and verbose). This package skips both — you attach the TypeScript you want, in PHP, and generate .ts with one command. Three annotation styles give you three levels of control:
#[TS]— write real TypeScript verbatim when you need unions, templates, or generics#[TSType]— auto-infer from PHP property types for simple DTOs and data classes#[TSEnum]— auto-generate TypeScript enums from PHP backed or unit enums
Table of Contents
- Requirements
- Installation
- Quick Start
- Usage
- Resources, Collections, and Inertia
- Ordering in the Output File
- File Preservation
- Laravel Boost
- Configuration
- Roadmap
- Testing
- Changelog
- Credits
- Contributing
- License
Requirements
| Laravel | PHP |
|---|---|
| 13.x | 8.3, 8.4, 8.5 |
| 12.x | 8.2, 8.3, 8.4, 8.5 |
Installation
composer require brunoscode/laravel-ts-annotations
Publish the config file:
php artisan vendor:publish --tag=ts-annotations-config
Quick Start
// Raw TypeScript — full control #[TS(<<<'TS' export type UserResponse = { id: number; name: string; role: 'admin' | 'editor' | 'viewer'; } TS)] class UserResource extends JsonResource {} // Auto-inferred from class properties #[TSType] class UserData { public function __construct( public readonly int $id, public readonly string $name, public readonly ?string $email, ) {} } // Auto-inferred from PHP enum #[TSEnum] enum Status: string { case Active = 'active'; case Inactive = 'inactive'; }
php artisan ts:generate
// resources/js/types/generated.ts ← generated automatically // [ts-annotations:start] // ⚠️ Auto-generated — do not edit between these comments. // --- App\Http\Resources\UserResource --- export type UserResponse = { id: number; name: string; role: 'admin' | 'editor' | 'viewer'; } // --- App\Data\UserData --- export type UserData = { readonly id: number; readonly name: string; readonly email: string | null; } // --- App\Enums\Status --- export enum Status { Active = 'active', Inactive = 'inactive', } // [ts-annotations:end]
Usage
#[TS] — raw TypeScript
Write any TypeScript verbatim. Use this when you need union types, template literals, generics, or any construct that can't be inferred from PHP types.
Usable on classes and on individual methods. #[TS] is repeatable — stack it as many times as needed.
use BrunosCode\LaravelTsAnnotations\Attributes\TS; #[TS(<<<'TS' export type UserResponse = { id: number; name: string; role: 'admin' | 'editor' | 'viewer'; } TS)] class UserResource extends JsonResource {}
On controller methods — keeps each type next to the action it describes:
class UserController extends Controller { #[TS(<<<'TS' export type UserListResponse = { data: UserResponse[]; total: number; per_page: number; } TS)] public function index(): JsonResponse { ... } #[TS(<<<'TS' export type UserStoreResponse = { data: UserResponse; message: string; } TS)] public function store(StoreUserRequest $request): JsonResponse { ... } }
Heredoc indentation: Place the closing
TSmarker at the same indentation level as the type body. PHP strips that many leading spaces from every line, giving zero-based indentation in the output.
#[TSType] — auto-infer from class properties
Inspects all public non-static properties (including promoted constructor params) via Reflection and maps PHP types to TypeScript. The readonly modifier is preserved.
Only properties declared on the class itself are emitted — properties inherited from a parent class are skipped. Annotate the parent with #[TSType] too if you need its properties in a separate type.
use BrunosCode\LaravelTsAnnotations\Attributes\TSType; #[TSType] class OrderData { public function __construct( public readonly int $id, public readonly string $reference, public readonly float $total, public readonly bool $paid, public readonly ?string $note, ) {} }
Generates:
export type OrderData = { readonly id: number; readonly reference: string; readonly total: number; readonly paid: boolean; readonly note: string | null; }
PHP → TypeScript type mapping:
| PHP | TypeScript |
|---|---|
string |
string |
int, float |
number |
bool |
boolean |
?T |
T | null |
T|U |
T | U |
array |
unknown[] |
mixed |
any |
object |
object |
void, never |
void, never |
self, static |
this |
T & U (intersection) |
T & U |
Carbon\Carbon, CarbonImmutable, Illuminate\Support\Carbon |
string |
Illuminate\Support\Collection, Eloquent Collection |
unknown[] |
| Any other class | short class name |
Only the exact Carbon/Collection FQCNs above are remapped. A different class named Collection (e.g. your own App\Support\Collection) falls through to the short-name rule and becomes the literal Collection, not unknown[].
Use the optional name parameter to override the TypeScript identifier:
#[TSType(name: 'IOrder')] class OrderData { ... } // → export type IOrder = { ... }
#[TSEnum] — auto-generate from PHP enums
Reads enum cases and their backing values automatically. No body to write.
use BrunosCode\LaravelTsAnnotations\Attributes\TSEnum; // String-backed #[TSEnum] enum Status: string { case Active = 'active'; case Inactive = 'inactive'; case Pending = 'pending'; } // → export enum Status { Active = 'active', Inactive = 'inactive', Pending = 'pending', } // Int-backed #[TSEnum] enum Priority: int { case Low = 1; case Medium = 2; case High = 3; } // → export enum Priority { Low = 1, Medium = 2, High = 3, } // Unit enum (no backing type) — case name used as string value #[TSEnum] enum Direction { case North; case South; case East; case West; } // → export enum Direction { North = 'North', South = 'South', East = 'East', West = 'West', }
Targeting a specific output file
All three annotations accept an output parameter:
#[TS(<<<'TS' export type AdminDashboard = { users_count: number; revenue: number; } TS, output: 'admin')] #[TSType(output: 'admin')] class AdminUserData { ... } #[TSEnum(output: 'admin')] enum AdminRole: string { ... }
The key must match one defined in config/ts-annotations.php.
Run the generator
# Generate all output files php artisan ts:generate # Generate only one specific file php artisan ts:generate --output=admin # Preview what would be written without touching any file php artisan ts:generate --dry-run
Resources, Collections, and Inertia
Laravel Resources give you explicit control over the shape of data sent to the frontend — they transform Eloquent models rather than leaking raw attributes. Annotating them with #[TS] keeps that contract in sync with your TypeScript automatically.
1. Define the resource shape
use BrunosCode\LaravelTsAnnotations\Attributes\TS; use Illuminate\Http\Resources\Json\JsonResource; #[TS(<<<'TS' export type UserResource = { id: number; name: string; email: string; role: 'admin' | 'editor' | 'viewer'; } TS)] class UserResource extends JsonResource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'name' => $this->name, 'email' => $this->email, 'role' => $this->role, ]; } }
Keeping the annotation on the Resource rather than the controller means the type and the transformation logic live together. If you tighten toArray, you update the #[TS] block in the same file.
2. Annotate controller methods for Inertia
The default config injects two generic helpers at the top of every generated file:
export type CollectionResource<T> = { data: T[] }; export type PaginatedResource<T> = { data: T[]; total: number; per_page: number; current_page: number; last_page: number; from: number | null; to: number | null; // ... };
Reference them directly in the #[TS] attribute on each controller method that renders an Inertia page:
use BrunosCode\LaravelTsAnnotations\Attributes\TS; use Inertia\Inertia; class UserController extends Controller { #[TS('export type UserIndexProps = { users: PaginatedResource<UserResource> }')] public function index(): \Inertia\Response { return Inertia::render('Users/Index', [ 'users' => UserResource::collection(User::paginate()), ]); } #[TS('export type UserListProps = { users: CollectionResource<UserResource> }')] public function list(): \Inertia\Response { return Inertia::render('Users/List', [ 'users' => UserResource::collection(User::all()), ]); } #[TS('export type UserShowProps = { user: UserResource }')] public function show(User $user): \Inertia\Response { return Inertia::render('Users/Show', [ 'user' => new UserResource($user), ]); } }
3. Generated TypeScript
// resources/js/types/generated.ts // [ts-annotations:start] // ⚠️ Auto-generated — do not edit between these comments. export type CollectionResource<T> = { data: T[] }; export type PaginatedResource<T> = { data: T[]; total: number; per_page: number; current_page: number; last_page: number; from: number | null; to: number | null; first_page_url: string; last_page_url: string; next_page_url: string | null; prev_page_url: string | null; path: string; }; // --- App\Http\Resources\UserResource --- export type UserResource = { id: number; name: string; email: string; role: 'admin' | 'editor' | 'viewer'; } // --- App\Http\Controllers\UserController --- export type UserIndexProps = { users: PaginatedResource<UserResource> } export type UserListProps = { users: CollectionResource<UserResource> } export type UserShowProps = { user: UserResource } // [ts-annotations:end]
4. Consume in an Inertia component
<script setup lang="ts"> import type { UserIndexProps } from '@/types/generated' const props = defineProps<UserIndexProps>() // props.users.data → UserResource[] // props.users.total → number // props.users.current_page → number </script>
<script setup lang="ts"> import type { UserShowProps } from '@/types/generated' const props = defineProps<UserShowProps>() // props.user.id, props.user.name, props.user.role — fully typed </script>
CollectionResource<T>andPaginatedResource<T>are injected via theimportskey inconfig/ts-annotations.php. Customise them there or add any other shared helpers your app needs.
Ordering in the Output File
Entries follow file-scan order across classes. Within a single class they are emitted in this fixed order:
- Class-level
#[TS]attributes #[TSEnum](only on enums)#[TSType](inferred from the class)- Method-level
#[TS]attributes, sorted by line number
There is no global grouping by attribute type. A #[TSEnum] in a class that is scanned before a #[TS] resource appears first in the output. The result looks grouped only when your scan paths are themselves ordered by kind (e.g. Http, then Enum, then Data) — that grouping comes from scan order, not from an intrinsic sort.
Each entry is preceded by a source comment:
// --- App\Http\Resources\UserResource --- export type UserResponse = { ... } // --- App\Enums\Status --- export enum Status { ... } // --- App\Data\UserData --- export type UserData = { ... }
File Preservation
The generator only touches the section between the two marker comments. Everything outside the markers — manual imports, custom types, hand-written utilities — is left untouched on every run.
// My manual import — never overwritten import type { CustomHelper } from './helpers' // [ts-annotations:start] // ⚠️ Auto-generated — do not edit between these comments. // Generated at: 2026-05-10 12:00:00 // --- App\Http\Resources\UserResource --- export type UserResponse = { ... } // [ts-annotations:end] // My local type — never overwritten export type LocalState = 'idle' | 'loading' | 'error'
If a file doesn't exist yet, it is created from scratch. If it exists but has no markers, the generated block is appended at the end.
Laravel Boost
This package ships a Laravel Boost skill. If you use Boost, run:
php artisan boost:install
and select brunoscode/laravel-ts-annotations when prompted. The skill teaches your AI agent how to use #[TS], #[TSType], and #[TSEnum] attributes, run ts:generate, and manage the generated output.
Configuration
// config/ts-annotations.php return [ // Directories scanned recursively for all annotation types. 'scan' => [ app_path('Http'), // covers Resources, Controllers, Requests, Middleware app_path('Enum'), // enums annotated with #[TSEnum] app_path('Data'), // DTOs annotated with #[TSType] ], // Output .ts files. The array key is referenced in the `output` param. 'outputs' => [ 'default' => [ 'path' => resource_path('js/types/generated.ts'), // Lines written verbatim at the top of the generated section on every run. // Useful for shared generics like CollectionResource / PaginatedResource. 'imports' => [ 'export type CollectionResource<T> = { data: T[] };', '', 'export type PaginatedResource<T> = {', ' data: T[];', ' total: number;', ' per_page: number;', ' current_page: number;', ' last_page: number;', ' from: number | null;', ' to: number | null;', ' first_page_url: string;', ' last_page_url: string;', ' next_page_url: string | null;', ' prev_page_url: string | null;', ' path: string;', '};', ], ], // 'admin' => [ // 'path' => resource_path('js/types/admin.ts'), // 'imports' => [], // ], ], // Comment markers that delimit the generated section. // Everything outside the markers is preserved on re-generation. 'markers' => [ 'start' => '// [ts-annotations:start]', 'end' => '// [ts-annotations:end]', ], ];
Roadmap
Planned, not yet shipped:
--watchflag for automatic regeneration on file change
Testing
composer test
Changelog
Please see CHANGELOG.md for what has changed recently.
Credits
Contributing
Contributions are welcome! Please submit a pull request or open an issue to discuss what you would like to change.
License
The MIT License (MIT). Please see License File for more information.