abetwothree / laravel-ts-publish
Create TypeScript declaration types from your PHP models, enums, and other cast classes
Fund package maintenance!
Requires
- php: ^8.4
- composer/class-map-generator: ^1.7.2
- illuminate/contracts: ^13.0||^12.0
- laravel/prompts: ^0.3.21
- laravel/ranger: ^0.1.12
- laravel/surveyor: ^0.1.9
- nikic/php-parser: ^5.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- archtechx/enums: ^1.1
- awobaz/compoships: ^2.5 || ^3.0
- inertiajs/inertia-laravel: ^3
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0||^10.0.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-arch: ^5.0.0
- pestphp/pest-plugin-laravel: ^5.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
This package is auto-updated.
Last update: 2026-08-21 17:12:40 UTC
README
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
- 🚀 Usage
- 🏷️ Enums
- 🗃️ Models
- 📡 API resources
- 🚗 Routes
- 📝 Form requests
- 📡 Broadcast channels
- 🎤 Broadcast events
- 🌉 Inertia
- 🔑 Vite env
- 🧬 Extending interfaces
- ❌ Excluding content
- 🔤 Casing configurations
- 🌐 Enum API resource
- 📂 Modular publishing
- 🔧 Customizing the pipeline
- ⚡ Pre-command hook
- 💾 Cache generation
- 📤 Output options
- ⚙️ Configuration reference
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 enableenums.auto_include_methods/enums.auto_include_static_methodsto 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'sBackedEnum.- PHPDoc-aware — class, case, and method doc blocks are carried over as JSDoc comments automatically.
- Filtering — the same
included/excluded/additional_directoriesconfig 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 templates —
models.templatecontrols whether properties/mutators/relations are generated as separate interfaces (default) or combined into onemodel-fullinterface. - Smart nullable relations — singular relations (
HasOne,BelongsTo,MorphOne, ...) are automatically typed with| nullbased on the relation type and foreign key nullability, with a config to override the strategy per relation type. - Annotate instead of configuring —
@property/@property-readtags,@phpstan-typealiases,Attribute<>generics,@return MorphTo<A|B, $this>,AsEnumCollection::of()/AsCollection::of(), and anArrayableDTO'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.$hiddenand write-only accessors — hidden attributes publish by default.models.exclude_hiddenopts out for model and resource interfaces alike, so a resource'sexcept()or whole-model delegation loses the column too, thoughonly(['password'])still keeps one you name explicitly. A write-onlyAttribute::make(set:)resolves from its@return Attribute<Get, Set>generic, then from a same-named column, and failing both is omitted rather than emitted asunknown.#[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}Resourceinterface usingAsEnum<>, for when you've resolved a raw enum column to a full enum instance (e.g. viaStatus.from(user.status)). - Filtering — the same
included/excluded/additional_directoriesconfig 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 methods —
when(),unless(),whenLoaded(),whenHas(),whenAppended(),whenNotNull(),whenCounted(),whenAggregated(),whenExistsLoaded(),whenPivotLoaded(), andtransform()all become optional (?) properties, and passing an explicit default makes the property required. - Nested & collection resources —
SomeResource::make()/::collection()(ornew SomeResource(...)) resolve to imported resource types, including self-references; a collection carrying#[PreserveKeys]or$preserveKeys = trueemitsRecord<string, R>instead ofR[]. merge()/mergeWhen()/mergeUnless(), parenttoArray()spreads, trait method spreads, and a barereturn $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 asAsEnum<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_directoriesconfig pattern used by enums and models. - Relation
only()/except()—$this->relation->only([...])/->except([...])references the related model's generated interface asPick<Model, 'a' | 'b'>(except()picks the complement, every other column), keeping its#[TsCasts]and@propertyrefinements, whenever the relation resolves to a single model and every filtered key is a real database column. Anything else expands inline, whereexcept()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 barereturn parent::toArray($request);forms are unchanged and still idiomatic — see Inheriting a ParenttoArray(). - Model
toArray()spreads —[...$user->toArray(), 'flag' => true]types asOmit<User, 'flag'> & { flag: boolean }instead of collapsing tounknown[], theOmit<>keeping PHP's later-key-wins from collapsing the collision tonever. The arm references{Model}rather than re-deriving its shape, so a relation loaded before the spread is missing from the type and$hiddencolumns stay in it unlessmodels.exclude_hiddenis on — see ModeltoArray()Spread. toResource()/toResourceCollection()— both resolve through an explicitSomeResource::classargument, 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 tounknowninstead of importing a file that never gets written — seetoResource()andtoResourceCollection().- Same-basename class aliasing — when two classes in different namespaces share a class name (
App\Models\UserandCrm\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
_queryescape hatch and amergeQueryoption for updating the current page's query string. .form()helper — builds{ action, method }for HTML forms, including Laravel's_methodspoofing forPUT/PATCH/DELETE, and mappingHEADto a plain GET form action (HTML forms can't submitHEAD).- Inertia integration — page-prop types and the component name are inferred and attached automatically when
inertia.enabledis 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
FormRequesttype-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, andarray_keys:a,bname an array's keys without a full nested shape, resolving to a keyed object (config: { timezone?: unknown }) instead ofunknown[]. - Numeric
in:literals — a string-formin:1,2,3emits an unquoted1 | 2 | 3when a sibling rule declares the field numeric. The same list of rules now drives both that decision and thenumbermapping, sodecimal,digits, anddigits_betweencount alongsideinteger/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 ownvalidateIn()compares the raw string and would reject2.5. - Nested/wildcard composition —
parent.*.childandparent.childdot-notation rules compose recursively into their nearest undotted ancestor (tags.*→tags: string[],order.items.*.sku→order?: { 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 & nullability —
required/sometimescontrol whether a field is optional (?),nullableadds| null, andmissing/prohibitedfields 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()->iddirectly) fall back toRecord<string, unknown>instead of failing the publish. - Route integration — a controller action type-hinted to a
FormRequestautomatically gets its route export wrapped withannotateRequestPayload<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_directoriesconfig 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. BroadcastChanneltype — 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.tsoutput.
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}Typealias, both with automatic imports. broadcastAs()support — a custom Echo event name frombroadcastAs()is used as-is; otherwise the Echo name defaults to Laravel's.Fully.Qualified.ClassNameconvention.#[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.tsfile that augments@laravel/echo's (or@laravel/echo-vue/-react/-svelte's, auto-detected)Eventsinterface for fully-typedEcho.private(...).listen()calls. - Filtering — the same
included/excluded/additional_directoriesconfig 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 fromshare()(including a spread...parent::share($request)) is statically resolved to a TypeScript type, no running the app required. #[TsCasts]/@returndocblock 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$withAllErrorsproperty istrue, 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 withInertia.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]/$preserveKeysresource collection types itsdatamember asRecord<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(), andcursorPaginate()are all recognised, in both thenew SomeCollection(...)andSomeResource::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 withVITE_are included, matching Vite's own convention for client-exposed environment variables. .envwith.env.examplefallback — reads.envfirst, falling back to.env.examplewhen.envdoesn't exist (useful in CI or fresh clones), or point it at a specific file withvite_env.source_file.- Always
string— every variable is typed asstring, 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 matchingts_extends.models/ts_extends.resources/ts_extends.form_requests/ts_extends.broadcast_eventsconfig 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, withtypesnaming 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/_existsproperties 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 thenameparameter 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 sameenums.method_casecasing. - Works standalone or embedded — instantiate directly (
new EnumResource($enum)) for a top-level API response, or useEnumResource::make()inside another resource'stoArray()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 usedEnumResource.- Auto-generated
{Model}Resourceinterfaces — any model with enum-cast columns automatically gets a companion set of interfaces usingAsEnum<>, so you don't have to hand-composeOmit+AsEnumyourself. - Unit enum support — enums without a backed type still work;
valuemirrors the casenameandbackedisfalse.
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\Invoice→accounting/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.tsre-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_classconfig keys. - Abstract base classes —
CoreCollector,CoreGenerator,CoreTransformer, andCoreWriterdefine the exact method contract a custom class must implement. - Cache-compatible generators — a custom
*.generator_classcan opt into the generation cache with theRehydratesFromCachetrait, 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=truerun 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*_classoverride (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_directorieslists 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--sourceand--preview=true.- Always bypassed by
--sourceand--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.storeat 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.