abetwothree/laravel-ts-publish

Create TypeScript declaration types from your PHP models, enums, and other cast classes

Maintainers

Package info

github.com/abetwothree/laravel-ts-publish

pkg:composer/abetwothree/laravel-ts-publish

Transparency log

Fund package maintenance!

abetwothree

Statistics

Installs: 4 668

Dependents: 0

Suggesters: 0

Stars: 6

Open Issues: 2


README

Latest Version on Packagist Laravel Compatibility GitHub Tests Action Status Coverage GitHub Code Style Action Status Total Downloads

Laravel TypeScript Publisher Logo

Transform Laravel models, enums, API resources, routes, broadcast events, and custom cast classes into TypeScript declaration types.

Enums and routes become functional objects. Enums support PHP-like enum functions and can include your own methods.

Every Laravel app is different, so what the package infers is yours to override, and the backend and frontend tooling keeps your frontend types in sync with your PHP as it changes.

For examples of the generated TypeScript output, see these output examples.

Also by me

Table of contents

Installation

Requires PHP 8.4+ and supports Laravel 13, 12

Upgrading from version 1.x? Please refer to the Upgrade Guide for instructions on migrating from version 1.x to the current version.

You can install the package via composer:

composer require abetwothree/laravel-ts-publish

You can publish the config file with:

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

Optionally, you can publish the views using:

php artisan vendor:publish --tag="laravel-ts-publish-views"

Usage

Publishing types

You can publish your TypeScript declaration types using the ts:publish Artisan command:

php artisan ts:publish

The first run caches its work, so reruns only regenerate what changed. Use --fresh to rebuild everything. See Cache generation.

php artisan ts:publish --fresh

By default, generated types are written to resources/js/types/data/.

The package scans the standard Laravel directories (app/Models, app/Enums, app/Http/Resources). Change any of that in the published config file.

For a full installation and setup guide, see the Installation & Setup documentation.

Preview mode

You can preview the generated TypeScript output in the console without writing any files by using --preview=true:

php artisan ts:publish --preview=true

Warning

The =true is required. --preview is declared with a default value ({--preview=false}), so a bare --preview flag parses as unset rather than true, and the command writes real files instead of previewing them.

Useful for debugging, or for reviewing what will be generated before it hits disk.

Single-file republishing

You can republish a single enum, model, or resource instead of the entire set by using the --source option with a fully-qualified class name or file path:

php artisan ts:publish --source="App\Enums\Status"
php artisan ts:publish --source="app/Enums/Status.php"
php artisan ts:publish --source="App\Http\Resources\UserResource"

On a large project this is much faster than a full publish. The Vite plugin uses it automatically during development to republish only the file that changed.

Automatic publishing after migrations

By default, this package will automatically re-publish your TypeScript declaration types after running migrations. This ensures your TypeScript types stay in sync with your database schema changes.

You can disable this behavior in the config file or via environment variable:

// config/ts-publish.php

'run_after_migrate' => false,
TS_PUBLISH_RUN_AFTER_MIGRATE=false

Filtering models, enums & resources

Choose what to include or exclude, and add directories to search. By default everything in app/Models, app/Enums, and app/Http/Resources is included.

// config/ts-publish.php

'models' => [
    // Only publish these specific models (leave empty to include all)
    'included' => [
        App\Models\User::class,
        App\Models\Post::class,
    ],

    // Exclude specific models from publishing
    'excluded' => [
        App\Models\Pivot::class,
    ],

    // Search additional directories for models
    'additional_directories' => [
        'modules/Blog/Models',
    ],
],

Similar options are available for other content types like enums, events, resources, etc., allowing you to specify included, excluded, and additional_directories for each type.

Tip

Include and exclude settings accept both fully-qualified class names and directory paths. When a directory is provided, all matching classes within it will be discovered automatically.

Conditional publishing

You can choose to publish only enums, only models, or only resources, either through configuration or command flags.

Via configuration

Disable enum, model, or resource publishing entirely in the config file:

// config/ts-publish.php

'enums' => ['enabled' => true],
'models' => ['enabled' => true],
'resources' => ['enabled' => true],

Setting any to false will skip that type on every run, including automatic post-migration publishing.

Via command flags

Use one of the --only-* flags to limit a single run to a specific type: --only-enums, --only-models, --only-resources, --only-routes, --only-form-requests, --only-broadcast-channels, or --only-broadcast-events.

php artisan ts:publish --only-enums
php artisan ts:publish --only-models
php artisan ts:publish --only-resources

The flags cannot be combined. Passing two returns an error.

There's also --only-functional, which publishes only type-erasure-safe output (enums, routes, form requests, broadcast channels/events) while skipping models and resources. The Vite plugin appends it on vite build, since interfaces are erased at compile time anyway. Combined with another --only-* flag, it wins.

Config & flag conflicts

When a command flag requests a type that is disabled in config (e.g. --only-enums while enums.enabled is false), the command will prompt you to confirm whether to override the config setting. In non-interactive environments (CI, queued jobs, post-migration hooks), the config value is respected and the command exits gracefully.

If all types end up disabled (all config values are false and no override flag is given), the command prints a warning and exits with a success status.

Verbosity levels

The ts:publish command supports three verbosity levels using the standard Artisan verbosity flags:

Flag Output
--quiet / -q Nothing but the exit code. Suits automated tooling like the Vite plugin.
(default) A compact summary showing the output directory, file counts, and any extra files generated (barrels, globals, JSON).
--verbose / -v Detailed tables listing every generated file with per-file metadata (cases, methods, columns, mutators, relations).
# Compact summary (default)
php artisan ts:publish

# Detailed tables
php artisan ts:publish -v

# Silent — for scripts, CI, or the Vite plugin
php artisan ts:publish --quiet

Quiet mode still writes every file; it suppresses console output only. The Vite plugin passes it by default because it only needs the exit code.

Enums

PHP enums become functional TypeScript objects rather than a bare union of values, with PHP-like behavior (.from(), .tryFrom(), .cases()) powered by @tolki/ts. Your own enum methods and static methods can come along too.

enum Status: string
{
    case Active = 'active';
    case Inactive = 'inactive';

    #[TsEnumMethod]
    public function label(): string
    {
        return match($this) {
            self::Active => 'Active User',
            self::Inactive => 'Inactive User',
        };
    }
}
import { Status } from '@js/types/data/enums';

Status.Active;                // 'active'
Status.label.Active;          // 'Active User'
Status.from('active').label;  // 'Active User' — a PHP-like enum "instance"

Key capabilities:

  • #[TsEnumMethod] / #[TsEnumStaticMethod] — opt individual instance/static methods into the TypeScript output (or enable enums.auto_include_methods / enums.auto_include_static_methods to include all public methods automatically).
  • #[TsEnum] / #[TsCase] — rename the enum or a case, or add a JSDoc description, when the PHP name doesn't match what you want on the frontend.
  • {Name}Type / {Name}Kind — generated type aliases for validating a raw case value or case name.
  • defineEnum() from @tolki/ts — wraps the enum so you can call .from(), .tryFrom(), and .cases() on it just like PHP's BackedEnum.
  • PHPDoc-aware — class, case, and method doc blocks are carried over as JSDoc comments automatically.
  • Filtering — the same included / excluded / additional_directories config pattern used by models and resources.
  • #[TsExclude] — exclude an entire enum or specific methods from the output. See Excluding with #[TsExclude].
  • EnumResource — an HTTP JSON resource for returning flattened, instance-specific enum data from your API routes. See JSON enum HTTP API resource.

For every attribute option, the metadata/@tolki/ts integration, the Vite plugin, and the full behavior of auto-including methods, see the full Enums documentation.

Models

Eloquent models become TypeScript interfaces for their properties, mutators, and relations. They are split into separate interfaces by default, so a page imports only the parts it uses.

class User extends Model
{
    public function casts(): array
    {
        return ['status' => Status::class];
    }

    protected function initials(): Attribute
    {
        return Attribute::get(fn (): string => /* ... */);
    }

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }
}
import type { User, UserMutators, UserRelations } from '@js/types/data/models';

// User          → id: number; status: StatusType; ...
// UserMutators  → initials: string
// UserRelations → posts: Post[]; posts_count: number; posts_exists: boolean

Key capabilities:

  • Split or full templatesmodels.template controls whether properties/mutators/relations are generated as separate interfaces (default) or combined into one model-full interface.
  • Smart nullable relations — singular relations (HasOne, BelongsTo, MorphOne, ...) are automatically typed with | null based on the relation type and foreign key nullability, with a config to override the strategy per relation type.
  • Annotate instead of configuring@property / @property-read tags, @phpstan-type aliases, Attribute<> generics, @return MorphTo<A|B, $this>, AsEnumCollection::of() / AsCollection::of(), and an Arrayable DTO's own typed properties all sharpen a column's type with no #[TsCasts] needed, and PHPStan/Larastan read the same annotations. See Typing attributes without #[TsCasts].
  • PHPDoc-aware — class, column, mutator, and relation doc blocks are carried over as JSDoc comments automatically.
  • #[TsCasts] / #[TsType] — for more advanced TypeScript types for columns, mutators, relations, or an entire custom cast class, including custom types imported from your own files.
  • $hidden and write-only accessors — hidden attributes publish by default. models.exclude_hidden opts out for model and resource interfaces alike, so a resource's except() or whole-model delegation loses the column too, though only(['password']) still keeps one you name explicitly. A write-only Attribute::make(set:) resolves from its @return Attribute<Get, Set> generic, then from a same-named column, and failing both is omitted rather than emitted as unknown.
  • #[TsExclude] — exclude an entire model, or a specific accessor/relation, from the output.
  • Laravel 13 model attributes#[Table], #[Hidden], #[Visible], #[Appends], and #[Connection] are honoured automatically, no configuration needed. See Laravel 13 Model Attributes for the full attribute-by-attribute table.
  • Enum-typed columns also generate a matching {Model}Resource interface using AsEnum<>, for when you've resolved a raw enum column to a full enum instance (e.g. via Status.from(user.status)).
  • Filtering — the same included / excluded / additional_directories config pattern used by enums and resources.

Tip

Still seeing unknown in the output? The annotation checklist indexes each case by symptom and names the docblock tag that fixes it. PHPStan and Larastan read all of them too.

If it still comes out unknown, open an issue with the PHP and the generated TypeScript so we can look.

For the template comparison, nullable relation strategies, every attribute option, and the complete type-mapping reference, see the full Models documentation.

API resources

This package reads a JsonResource's toArray() method statically and generates the interface from it, so you don't hand-maintain a second type for what your API already returns. See Laravel's API Resources.

/** @mixin User */
class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'role' => EnumResource::make($this->role),
            'posts' => PostResource::collection($this->whenLoaded('posts')),
        ];
    }
}
import { type AsEnum } from '@tolki/ts';
import { Role } from '../enums';
import type { PostResource } from '.';

export interface UserResource {
    id: number;
    name: string;
    role: AsEnum<typeof Role> | null;
    posts?: PostResource[];
}

Key capabilities:

  • Model-aware type resolution — property types come from the backing Eloquent model's database schema and casts, with the model resolved via #[TsResource(model:)], @mixin, naming convention, or #[UseResource].
  • Conditional methodswhen(), unless(), whenLoaded(), whenHas(), whenAppended(), whenNotNull(), whenCounted(), whenAggregated(), whenExistsLoaded(), whenPivotLoaded(), and transform() all become optional (?) properties, and passing an explicit default makes the property required.
  • Nested & collection resourcesSomeResource::make() / ::collection() (or new SomeResource(...)) resolve to imported resource types, including self-references; a collection carrying #[PreserveKeys] or $preserveKeys = true emits Record<string, R> instead of R[].
  • merge() / mergeWhen() / mergeUnless(), parent toArray() spreads, trait method spreads, and a bare return $this->method() (resolved transitively, the same as its ...$this->method() spread form) — all contribute properties, with types resolved from PHPDoc @return array{...} shapes or #[TsCasts].
  • EnumResource::make() — exposes an enum-cast property as AsEnum<typeof Enum> with automatic imports.
  • #[TsResource] / #[TsCasts] / #[TsExclude] — override the interface name/model/description, override or add property types, or exclude a resource entirely. See Excluding with #[TsExclude].
  • Smart nullable relations — the same nullability-detection strategy used by models, with config to override the strategy per relation type.
  • Filtering — the same included / excluded / additional_directories config pattern used by enums and models.
  • Relation only() / except()$this->relation->only([...]) / ->except([...]) references the related model's generated interface as Pick<Model, 'a' | 'b'> (except() picks the complement, every other column), keeping its #[TsCasts] and @property refinements, whenever the relation resolves to a single model and every filtered key is a real database column. Anything else expands inline, where except() yields database columns only — see Relation Filters.
  • Resource inheritance — a resource that extends another resource and declares no toArray() of its own inherits the parent's shape and its backing model, walking up to the nearest ancestor that declares each. The explicit ...parent::toArray($request) spread and bare return parent::toArray($request); forms are unchanged and still idiomatic — see Inheriting a Parent toArray().
  • Model toArray() spreads[...$user->toArray(), 'flag' => true] types as Omit<User, 'flag'> & { flag: boolean } instead of collapsing to unknown[], the Omit<> keeping PHP's later-key-wins from collapsing the collision to never. The arm references {Model} rather than re-deriving its shape, so a relation loaded before the spread is missing from the type and $hidden columns stay in it unless models.exclude_hidden is on — see Model toArray() Spread.
  • toResource() / toResourceCollection() — both resolve through an explicit SomeResource::class argument, a #[UseResource] / #[UseResourceCollection] attribute, or Laravel's naming convention. Only the naming-convention guess is gated on this package actually emitting that resource, so an unpublished guess falls back to unknown instead of importing a file that never gets written — see toResource() and toResourceCollection().
  • Same-basename class aliasing — when two classes in different namespaces share a class name (App\Models\User and Crm\Models\User), every occurrence of that name inside a single property's type now resolves to its own aliased import, in source order — see Classes Sharing a Name Across Namespaces.

For every supported toArray() pattern, the full attribute reference, and nullable-relation strategies, see the full API Resources documentation.

Routes

Every controller action gets a functional route helper. The URL-building, parameter-binding, query-string, and form-spoofing logic lives in one defineRoute() factory from @tolki/ts rather than being generated inline for every route. The helpers are built to be spec-compliant with Laravel Wayfinder and work with Inertia the same way.

// resources/js/types/data/app/http/controllers/post-controller.ts (generated)
import { defineRoute, annotateRequestPayload } from '@tolki/ts';
import type { UpdatePostRequest } from '../requests/update-post-request';

export const update = annotateRequestPayload<UpdatePostRequest>()(defineRoute({
    name: 'posts.update',
    url: '/posts/{post}',
    methods: ['put'] as const,
    args: [{ name: 'post', required: true, _routeKey: 'id' }] as const,
}));
// Anywhere in your frontend
import { PostController } from '@js/types/data/app/http/controllers';

PostController.update({ post: 42 });           // { url: '/posts/42', method: 'put' }
PostController.update.form.put({ post: 42 });  // { action: '/posts/42', method: 'post' } — with `_method=PUT` spoofed
PostController.update(post);                   // pass the Post model instance directly

Key capabilities:

  • Structural typing — model and enum route bindings are typed without importing the PHP model or enum class into the route file.
  • Multiple calling conventions — named object, positional arguments, an array of positional arguments, or a bare model/scalar for single-parameter routes.
  • Query strings — extra keys become query parameters automatically, with a _query escape hatch and a mergeQuery option for updating the current page's query string.
  • .form() helper — builds { action, method } for HTML forms, including Laravel's _method spoofing for PUT/PATCH/DELETE, and mapping HEAD to a plain GET form action (HTML forms can't submit HEAD).
  • Inertia integration — page-prop types and the component name are inferred and attached automatically when inertia.enabled is on.
  • Inertia UI Table typing — routes rendering an Inertia UI Table get an automatically typed TableResource<Model> page prop without evaluating the table, with table-tainted controllers safely falling back instead of erroring.
  • Form Request payloads — a controller method's FormRequest type-hint automatically attaches its generated interface to the route.
  • Filtering#[TsExclude], wildcard/negation route-name patterns (routes.only / routes.except), middleware exclusion, and named-routes-only mode.

For every calling convention, model/enum binding rule, query-string behavior, route defaults, form-spoofing detail, and the Inertia/FormRequest typing helpers, see the full Routing documentation.

Form requests

A Form Request's rules() method is analyzed statically and becomes a TypeScript interface for the request payload, so you don't hand-maintain a second type for what your validation rules already define.

class StorePostRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'rating' => ['nullable', 'numeric'],
            'tags' => ['array'],
            'tags.*' => ['string'],
            'order.items.*.sku' => ['required', 'string'],
        ];
    }
}
import type { StorePostRequest } from '@js/types/data/form-requests';

// { title: string; rating?: number | null; tags?: string[]; order?: { items?: { sku: string }[] }; }

Key capabilities:

  • Rule-aware type inference — scalar, array, in:/Rule::in(), Rule::enum(), Rule::anyOf(), file, and dozens of other rules resolve to the matching TypeScript type. required_array_keys:a,b, in_array_keys:a,b, array:a,b, and array_keys:a,b name an array's keys without a full nested shape, resolving to a keyed object (config: { timezone?: unknown }) instead of unknown[].
  • Numeric in: literals — a string-form in:1,2,3 emits an unquoted 1 | 2 | 3 when a sibling rule declares the field numeric. The same list of rules now drives both that decision and the number mapping, so decimal, digits, and digits_between count alongside integer/int/numeric (['digits:1', 'in:1,2,3']1 | 2 | 3). Coercion still only happens when the literal round-trips losslessly: ['decimal:2', 'in:1.50,2.50'] stays '1.50' | '2.50', because Laravel's own validateIn() compares the raw string and would reject 2.5.
  • Nested/wildcard compositionparent.*.child and parent.child dot-notation rules compose recursively into their nearest undotted ancestor (tags.*tags: string[], order.items.*.skuorder?: { items?: { sku: string }[] }) instead of surviving as separate flat, quoted keys. Declaring the parent's own rules (e.g. 'order' => ['required', 'array']) makes the composed key required instead of optional.
  • Presence & nullabilityrequired/sometimes control whether a field is optional (?), nullable adds | null, and missing/prohibited fields are excluded from the interface entirely.
  • #[TsCasts] — override or add field types on the request class itself, the same attribute used by models and resources.
  • #[TsExtends] — extend shared interfaces, the same mechanism used by models and resources. See Extending interfaces.
  • Dynamic fallback — requests whose rules() can't be resolved without real HTTP context (e.g. reading $this->user()->id directly) fall back to Record<string, unknown> instead of failing the publish.
  • Route integration — a controller action type-hinted to a FormRequest automatically gets its route export wrapped with annotateRequestPayload<T>(). See Form Request Payload Types.
  • #[TsExclude] — exclude an entire request class from the output. See Excluding with #[TsExclude].
  • Filtering — the same included / excluded / additional_directories config pattern used by enums, models, and resources.

For the full rule-to-type mapping, every JSDoc metadata annotation, and all attribute options, see the full Form Requests documentation.

Broadcast channels

Every channel name in routes/channels.php compiles into one broadcast-channels.ts file: a BroadcastChannel template-literal union, plus a BroadcastChannels const with a nested accessor for every dynamic segment. You never hand-type a {placeholder} channel string on the frontend.

// routes/channels.php
Broadcast::channel('orders.{orderId}', function ($user, $orderId) {
    return true;
});

Broadcast::channel('public-announcements', PublicAnnouncementsChannel::class);
import { BroadcastChannels } from '@js/types/data/broadcast-channels';

BroadcastChannels.orders(42);               // 'orders.42'
BroadcastChannels["public-announcements"];  // 'public-announcements'

Key capabilities:

  • Dot-notation tree — multi-segment channel names (user.{userId}.notifications) become nested accessor objects, matching Laravel's own dot-notation channel naming.
  • Both registration styles — closure-based and class-based (Broadcast::channel('name', ChannelClass::class)) channels are collected identically, since only the channel name string drives the output.
  • BroadcastChannel type — a template-literal union of every registered channel name, handy for typing a generic "subscribe to any channel" helper.
  • Single combined file — unlike enums/models/resources/form requests, there's no per-item filtering or attributes; every registered channel is compiled into one broadcast-channels.ts output.

For the dot-notation tree algorithm, parameter typing, and quoted-key handling, see the full Broadcast Channels documentation.

Broadcast events

Every ShouldBroadcast and ShouldBroadcastNow event gets its own interface, built from its broadcastWith() return shape or, when there is none, its public constructor properties. A combined broadcast-events.ts index adds a BroadcastEvent union and a flat BroadcastEvents const of every Echo event name.

class OrderShipped implements ShouldBroadcast
{
    public function __construct(
        public int $orderId,
        public string $trackingNumber,
        public string $carrier,
    ) {}

    public function broadcastOn(): Channel
    {
        return new PrivateChannel("orders.{$this->orderId}");
    }
}
/** @see App\Events\OrderShipped */
export interface OrderShipped {
    orderId: number;
    trackingNumber: string;
    carrier: string;
}

Key capabilities:

  • broadcastWith() or public properties — when present, broadcastWith()'s return shape drives the interface (handy for hiding private fields); otherwise every public constructor-promoted property is used.
  • Model & enum-aware — a property typed as an Eloquent model resolves to Partial<Model>, and a PHP enum property resolves to the enum's {Name}Type alias, both with automatic imports.
  • broadcastAs() support — a custom Echo event name from broadcastAs() is used as-is; otherwise the Echo name defaults to Laravel's .Fully.Qualified.ClassName convention.
  • #[TsCasts] / #[TsExtends] — override property types or extend shared interfaces, the same attributes used by models, resources, and form requests.
  • #[TsExclude] — exclude an entire event class from the output. See Excluding with #[TsExclude].
  • Echo module augmentation — optionally generates an echo-broadcast-events.d.ts file that augments @laravel/echo's (or @laravel/echo-vue/-react/-svelte's, auto-detected) Events interface for fully-typed Echo.private(...).listen() calls.
  • Filtering — the same included / excluded / additional_directories config pattern used by enums, models, and form requests.

For the full property-resolution rules, import-conflict aliasing, and Echo augmentation setup, see the full Broadcast Events documentation.

Inertia

With inertia.enabled on, the package reads your HandleInertiaRequests middleware's share() method and writes inertia-config.d.ts: a module augmentation for @inertiajs/core plus a global Inertia.SharedData type. Every Inertia page gets typed shared props with no manual typing.

class HandleInertiaRequests extends Middleware
{
    public function share(Request $request): array
    {
        return [
            ...parent::share($request),
            'auth' => ['user' => $request->user()],
        ];
    }
}
declare global {
    namespace Inertia {
        type SharedData = { auth: { user: { id: number; name: string; email: string } | null }; /* ... */ };
    }
}

declare module '@inertiajs/core' {
    export interface InertiaConfig {
        sharedPageProps: Inertia.SharedData;
    }
}

Key capabilities:

  • Static share() analysis — every key returned from share() (including a spread ...parent::share($request)) is statically resolved to a TypeScript type, no running the app required.
  • #[TsCasts] / @return docblock overrides — override or add types for keys Surveyor can't infer on its own, the same #[TsCasts] attribute used everywhere else in the package.
  • errorValueType — automatically added to the augmentation when the middleware's $withAllErrors property is true, matching Inertia's validation error bag shape.
  • Route-linked page props — a related but separate piece: a controller action's Inertia::render() call gets its own page-prop type that intersects with Inertia.SharedData, threaded into that route's generated file automatically. See Inertia Integration in the Routing docs.
  • Preserve-keys resource collections — a paginated Inertia::render() prop backed by a #[PreserveKeys]/$preserveKeys resource collection types its data member as Record<string, T>, matching Laravel's key-preserving JSON shape instead of the default array.
  • Inline paginators — a paginator called directly inside the render array ('teams' => new TeamCollection(Team::query()->paginate(10))) is typed as a paginator, with no intermediate variable needed. paginate(), simplePaginate(), and cursorPaginate() are all recognised, in both the new SomeCollection(...) and SomeResource::collection(...) forms — see Paginating Inline in the Render Call.

For the full middleware discovery rules, the type-override priority order, and the generated file anatomy, see the full Inertia documentation.

Vite env

When vite_env.enabled is on, this package reads the VITE_-prefixed variables from your .env (or .env.example) file and generates a vite-env.d.ts that augments Vite's ImportMetaEnv interface, so import.meta.env.VITE_APP_NAME is typed without a hand-maintained declaration file.

VITE_APP_NAME=MyApp
VITE_APP_URL=https://example.test
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_APP_NAME: string;
  readonly VITE_APP_URL: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Key capabilities:

  • Automatic VITE_ filtering — only variables prefixed with VITE_ are included, matching Vite's own convention for client-exposed environment variables.
  • .env with .env.example fallback — reads .env first, falling back to .env.example when .env doesn't exist (useful in CI or fresh clones), or point it at a specific file with vite_env.source_file.
  • Always string — every variable is typed as string, matching what Vite actually provides at runtime regardless of the value's apparent type.
  • Skips cleanly when empty — no VITE_-prefixed variables found (or the source file doesn't exist) means no file is generated at all.

For the exact variable-parsing rules and source-file resolution order, see the full Vite Env documentation.

Extending interfaces with #[TsExtends] & configs

Sometimes a generated interface needs to extend a hand-written one, either for properties this package can't infer or to share common fields across many classes without duplication. The #[TsExtends] attribute (repeatable, and inherited from parent classes and traits) and the matching ts_extends.* config arrays both add to the generated interface's extends clause.

use AbeTwoThree\LaravelTsPublish\Attributes\TsExtends;

#[TsExtends('HasTimestamps', import: '@/types/common')]
#[TsExtends('Pick<Auditable, "created_by" | "updated_by">', import: '@/types/audit', types: ['Auditable'])]
class Warehouse extends Model
{
    // ...
}
import type { Auditable } from '@/types/audit';
import type { HasTimestamps } from '@/types/common';

export interface Warehouse extends HasTimestamps, Pick<Auditable, "created_by" | "updated_by">
{
    // ... model properties
}

Key capabilities:

  • Works on models, resources, form requests, and broadcast events — via #[TsExtends] and the matching ts_extends.models / ts_extends.resources / ts_extends.form_requests / ts_extends.broadcast_events config arrays.
  • Inherited from parent classes and traits — an attribute on a base class or a trait used by several classes is picked up automatically and combined with the class's own attributes.
  • Repeatable — stack multiple #[TsExtends] attributes on the same class, trait, or parent to extend several interfaces at once.
  • TypeScript helper support — wrap the interface name in Partial<>, Pick<>, Omit<>, or any other generic, with types naming which identifiers need importing.
  • Automatic deduplication & conflict resolution — the same extends clause reachable through multiple paths (e.g. a shared trait) is combined into one, and the same type name imported from two different paths is aliased automatically to avoid a collision.

For the full attribute reference, the trait/parent-class inheritance rules, and how naming conflicts are resolved, see the full Extending Interfaces documentation.

Excluding with #[TsExclude]

#[TsExclude] keeps a whole class out of the TypeScript output, or just one of its methods, accessors, relations, or actions. It works on enums, models, resources, form requests, broadcast events, and controllers. It's especially useful alongside enums.auto_include_methods / enums.auto_include_static_methods, letting you opt a single method back out of an otherwise-automatic inclusion.

use AbeTwoThree\LaravelTsPublish\Attributes\TsExclude;

class User extends Model
{
    #[TsExclude]
    protected function secretToken(): Attribute
    {
        return Attribute::make(get: fn (): string => 'hidden');
    }
}

The secretToken accessor above never reaches the generated User interface. Everything else on the model still publishes.

Key capabilities:

  • Works everywhere — enum classes/methods, model classes/accessors/relations, resource classes, form request classes, broadcast event classes, and controller classes/actions.
  • Always wins — even when #[TsEnumMethod], #[TsEnumStaticMethod], or an auto-include config would otherwise include something, #[TsExclude] takes priority.
  • Class-level exclusion removes the class from collection entirely — it won't appear in any generated output, index, or barrel file.
  • Member-level exclusion — removes that one method, accessor, relation, or action. Everything else on the class still publishes.

For the full target reference and a worked example for every supported type, see the full Excluding Content documentation.

Casing configurations

Three independent config options control the casing of generated names: models.relationship_case for model relations, enums.method_case for enum methods, and routes.method_casing for route actions. All three accept 'snake', 'camel', or 'pascal'.

// config/ts-publish.php

'models' => [
    'relationship_case' => 'snake', // default
],
'enums' => [
    'method_case' => 'camel', // default
],
'routes' => [
    'method_casing' => 'camel', // default
],

Key capabilities:

  • models.relationship_case — controls relation names and their generated _count / _exists properties in model interfaces (default 'snake').
  • enums.method_case — controls instance/static method key names in enum output (default 'camel'); an individual method can still override its own name via the name parameter on #[TsEnumMethod] / #[TsEnumStaticMethod].
  • routes.method_casing — controls the casing of each generated route action's exported identifier (default 'camel'); it only affects the generated variable name, never the underlying Laravel route name.
  • Independent settings — each config option only affects its own feature; there's no single global casing setting.

For the full casing tables and worked examples for all three settings, see the full Casing Configurations documentation.

JSON enum HTTP API resource

EnumResource is a Laravel JSON resource that turns any PHP enum case into a flat, API-friendly array. It runs through the same transformer pipeline as ts:publish, so every #[TsEnumMethod] and #[TsEnumStaticMethod] you configured appears in the response.

use AbeTwoThree\LaravelTsPublish\EnumResource;
use App\Enums\Status;

return new EnumResource(Status::Published);
{
    "name": "Published",
    "value": 1,
    "backed": true,
    "icon": "check",
    "color": "green"
}

Key capabilities:

  • Same pipeline as ts:publish — only #[TsEnumMethod] / #[TsEnumStaticMethod] methods (or all public methods when auto-include is on) are included, using the same enums.method_case casing.
  • Works standalone or embedded — instantiate directly (new EnumResource($enum)) for a top-level API response, or use EnumResource::make() inside another resource's toArray() to embed a rich enum object. See Enum Properties with EnumResource.
  • AsEnum<T, V?> from @tolki/ts — the TypeScript type companion that matches this exact response shape, so you can type an API response that used EnumResource.
  • Auto-generated {Model}Resource interfaces — any model with enum-cast columns automatically gets a companion set of interfaces using AsEnum<>, so you don't have to hand-compose Omit + AsEnum yourself.
  • Unit enum support — enums without a backed type still work; value mirrors the case name and backed is false.

For the full response shape, unit enum behavior, and the auto-generated model resource interfaces, see the full Enum API Resource documentation.

Modular publishing

Generated files always mirror your PHP namespace structure as a directory tree. There is no flat-output mode and no toggle to opt out. Modular and domain-driven apps (for example InterNACHI/modular) stay tidy, and a single-namespace app produces one app/ tree.

resources/js/types/data/
├── app/
│   ├── enums/
│   │   ├── role.ts
│   │   └── index.ts
│   ├── models/
│   │   ├── user.ts
│   │   └── index.ts
│   └── http/
│       └── resources/
│           ├── user-resource.ts
│           └── index.ts
├── accounting/
│   ├── enums/
│   │   ├── invoice-status.ts
│   │   └── index.ts
│   └── models/
│       ├── invoice.ts
│       └── index.ts
└── global.d.ts

Key capabilities:

  • Namespace-derived paths — every class's PHP namespace (minus the class name itself) is kebab-cased segment-by-segment and joined into a directory path, e.g. Accounting\Models\Invoiceaccounting/models/invoice.ts.
  • Automatic relative imports — cross-namespace imports (e.g. a model importing a related model from another namespace) are computed as relative paths automatically; no path aliases required.
  • Per-namespace barrel files — every namespace directory gets its own index.ts re-exporting everything inside it, so you can import from a namespace root instead of a specific file.
  • namespace_strip_prefix — strip a common namespace prefix (e.g. Modules\) from the output path when your app already nests everything under one root namespace.
  • Applies to every feature — models, enums, resources, form requests, broadcast events, and routes are all placed using the same namespace-derived path.

For the full kebab-casing algorithm, the relative-import-path rules, and the barrel file format, see the full Modular Publishing documentation.

Extending & customizing the pipeline

Every feature in this package runs through a Collector → Generator → Transformer → Writer → Template pipeline, though not every feature uses all five stages. Each stage is swappable per feature through the config file. Extend the built-in class, override the matching config key, and the rest of the pipeline keeps working as-is.

// config/ts-publish.php

'models' => [
    'transformer_class' => App\TypeScript\CustomModelTransformer::class,
],

Key capabilities:

  • Every feature is customizable — models, enums, resources, routes, form requests, broadcast channels, and broadcast events each expose their own *.collector_class / *.generator_class / *.transformer_class / *.writer_class config keys.
  • Abstract base classesCoreCollector, CoreGenerator, CoreTransformer, and CoreWriter define the exact method contract a custom class must implement.
  • Cache-compatible generators — a custom *.generator_class can opt into the generation cache with the RehydratesFromCache trait, the same way every built-in generator does.
  • Swap just the templates — publish and edit the Blade templates directly with php artisan vendor:publish --tag="laravel-ts-publish-views" if you only need to change output formatting, without writing any PHP classes.

For the full per-feature pipeline-stage reference, every abstract base class's method contract, and the cache rehydration mechanics, see the full Customizing the Pipeline documentation.

Pre-command hook

Register a closure with LaravelTsPublish::callCommandUsing() to run logic right before ts:publish executes, whether that is building directory lists, swapping pipeline classes, or reacting to feature flags. The closure only runs when the command actually runs, not at service provider boot time, so it never adds overhead to a normal request.

use AbeTwoThree\LaravelTsPublish\LaravelTsPublish;

public function boot(): void
{
    LaravelTsPublish::callCommandUsing(function () {
        config()->set('ts-publish.models.additional_directories', [
            'modules/Blog/Models',
            'modules/Shop/Models',
        ]);
    });
}

Key capabilities:

  • Runs on every invocation — a full ts:publish, a --source=... rerun, and a --preview=true run all trigger the hook identically, unconditionally, before any command flags are parsed.
  • Only one closure at a time — calling callCommandUsing() again replaces the previous closure entirely; it doesn't stack.
  • Set any config, not just directories — since it runs with the full config already loaded, the closure can set any ts-publish.* key, including swapping a *_class override (see Customizing the Pipeline).
  • Dynamic directory discovery — a common pattern is scanning the filesystem (e.g. with Symfony Finder) or a package's own module registry to build additional_directories lists that stay in sync automatically as modules are added or removed.

For worked examples (modular package integration, conditional pipeline swaps, feature-flag-driven publishing), the exact invocation timing, and how to safely reset the hook between tests, see the full Pre-Command Hook documentation.

Cache generation

After the first full publish, ts:publish can skip re-generating classes whose source files (and everything they depend on) haven't changed. The cache is busted automatically whenever the package version or your output-affecting config changes, and a class is only served from cache if every file it previously wrote still exists on disk.

// config/ts-publish.php

'cache' => [
    'enabled' => env('TS_PUBLISH_CACHE_ENABLED', true),
    'store' => env('TS_PUBLISH_CACHE_STORE'),
    'directory' => storage_path('framework/cache/ts-publish'),
    'key' => env('TS_PUBLISH_CACHE_KEY'),
],

Key capabilities:

  • Content-based fingerprinting — each class is fingerprinted over its own source file plus everything it depends on (parent classes, traits, interfaces, related models, and more); for routes, the route definitions themselves (URI, methods, name, middleware) are folded in too, since those live outside any class file.
  • --fresh — forces a full rebuild, ignoring and regenerating the cache from scratch. A no-op under --source and --preview=true.
  • Always bypassed by --source and --preview=true — single-class republishing and preview runs never read or write the cache.
  • File or Laravel cache store backend — defaults to a signed file-based cache; point cache.store at any Laravel cache store (redis, database, …) to keep the manifest there instead, without ever touching keys outside this package's own.
  • HMAC-signed & tamper-resistant — cache payloads are signed with your app key (or a dedicated cache.key) and deserialized with object instantiation disabled, so a corrupted or tampered cache file can never inject a PHP object.

For the full fingerprinting algorithm, the dependency-recording rules, the ProvidesCacheSignature extension point for custom generators, and both storage backends' internals, see the full Cache Generation documentation.

Output options

This package provides several output formats that can be enabled independently:

Config Key Default Description
output_to_files true Write individual .ts files with barrel index.ts exports
globals.enabled false Generate a global.d.ts file with a global TypeScript namespace
json.enabled false Output all generated definitions as a JSON file
watcher.enabled true Output a JSON list of collected PHP file paths (useful for file watchers)

When globals.enabled is enabled, a global declaration file is created that makes all your types available without explicit imports:

// config/ts-publish.php

'globals' => [
    'enabled' => true,
    'filename' => 'laravel-ts-global.d.ts',
],
'models' => [
    'namespace' => 'models',
],
'enums' => [
    'namespace' => 'enums',
],

When json.enabled is enabled, a laravel-ts-definitions.json file is written alongside the generated .ts files, containing every collected model, enum, resource, form request, and broadcast event as structured data (columns, cases, properties, and so on) rather than TypeScript source:

// config/ts-publish.php

'json' => [
    'enabled' => true,
    'filename' => 'laravel-ts-definitions.json',
],

The file has one top-level object per feature (models, enums, resources, formRequests, broadcastEvents), and every one of them is keyed by fully-qualified class name ("Workbench\\App\\Models\\User"), not by short class name. Each entry carries a name field holding the short name that used to be the key. Keying by FQCN is deliberate: two classes sharing a basename across namespaces (App\Models\User and Crm\Models\User) are common in larger apps, and a short-name key silently overwrites one with the other. Key your lookups by FQCN and read name for display. This is a breaking change for anything written against the older bare-name-keyed file.

The JSON output from watcher.enabled is designed to work with build tools and file watchers (like the @tolki/ts Vite plugin) that need to know which PHP source files were collected so they can trigger a re-publish when those files change.

Configuration reference

Every configuration option lives in config/ts-publish.php, organized by feature (models.*, enums.*, routes.*, cache.*, and so on). Publish the config file to customize any of it:

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

For the full list of every configuration key, its type, default, and description, see the complete Configuration Reference.

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please see License File for more information.