brunoscode/laravel-ts-annotations

Write raw TypeScript types in PHP attributes and generate .ts files with an Artisan command.

Maintainers

Package info

github.com/BrunosCode/laravel-ts-annotations

pkg:composer/brunoscode/laravel-ts-annotations

Statistics

Installs: 9

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.2 2026-05-11 07:10 UTC

This package is auto-updated.

Last update: 2026-05-11 07:11:35 UTC


README

Generate TypeScript types from PHP attributes with a single Artisan command. Three annotation styles — raw TypeScript, auto-inferred from class properties, and auto-inferred from enums — cover every common case.

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.

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]

Why this package?

Most solutions either infer TypeScript from PHP types (losing union types, template literals, generics) or go through a Swagger/OpenAPI intermediary (indirect and verbose). This package gives 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

Requirements

  • PHP 8.1+
  • Laravel 10, 11, 12, or 13

Installation

composer require brunoscode/laravel-ts-annotations

Publish the config file:

php artisan vendor:publish --tag=ts-annotations-config

Configuration

// config/ts-annotations.php

return [

    // Directories scanned recursively for all annotation types.
    'scan' => [
        app_path('Http'),       // covers Resources, Controllers, Requests, Middleware
        app_path('Data'),       // DTOs annotated with #[TSType]
        app_path('Enums'),      // enums annotated with #[TSEnum]
    ],

    // 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]',
    ],

];

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 TS marker 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.

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
Carbon\Carbon string
Collection unknown[]
Other class short class name

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> and PaginatedResource<T> are injected via the imports key in config/ts-annotations.php. Customise them there or add any other shared helpers your app needs.

Ordering in the output file

Within each output file, entries are written in this order:

  1. Class-level #[TS] attributes, in file-scan order
  2. #[TSEnum] entries, in file-scan order
  3. #[TSType] entries, in file-scan order
  4. Method-level #[TS] attributes, sorted by line number within each class

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.

Roadmap

  • --watch flag for automatic regeneration on file change

Testing

composer install
vendor/bin/phpunit

License

MIT — see LICENSE.