Models
The Laravel TypeScript Publisher turns your Eloquent models into TypeScript interfaces for their columns, accessors and relations. The types come from your database schema, casts and docblocks, so you don't keep a second copy of each model up to date by hand.
Model interfaces are plain TypeScript, so they don't need the @tolki/ts runtime that enums and routes use. The one exception is a model with enum-typed columns: its file imports the AsEnum type with import { type AsEnum } from '@tolki/ts'; for its enum-resolved interfaces.
How Models Are Generated
The ts:publish command publishes each model as follows:
- One file per model:
App\Models\Useris written toapp/models/user.ts, following the model's namespace. Each namespace directory also gets anindex.tsbarrel that re-exports its files (export * from './user'), the same as enums. - Split interfaces: columns, accessors and relations go into separate interfaces by default. See Model Templates.
- Column types: a column's type comes from the first source that applies. In order, those are a
#[TsCasts]entry, an accessor with the column's name, the model's cast incasts()or$casts, and the database column type. A custom cast class can set its own type with#[TsType]. When the result is still vague, such asunknown[], a class-level@propertytag refines it. The Type Mapping Reference lists the default types. - Accessors: new-style
Attributeaccessors and old-styleget{Name}Attribute()methods that aren't columns publish as mutators, typed from their return types, their docblocks or what their getters return. - Relations: each relation publishes, plus a
_countand an_existsproperty for it. - Metadata companions: an opt-in feature writes a runtime
{model}_meta.tsfile beside the interface. See Model Metadata.
Missing Tables
When a model has no attributes at all and its table doesn't exist, ts:publish prints a warning after its summary:
App\Models\User: Table [users] does not exist on connection [mysql], so its columns are not published. Run the migrations, then publish again.The interface is still published, with no columns, so the warning is how you tell a missing migration from a model that has no columns. A table that exists but lacks some columns isn't reported, and --quiet hides the warning.
Anatomy of a Generated Model
For a User model with enum-cast columns, accessors and relations, the published file looks like this:
import { type AsEnum } from "@tolki/ts";
import { MembershipLevel, Role } from "../enums";
import type { DatabaseNotification } from "../../illuminate/notifications";
import type { MembershipLevelType, RoleType } from "../enums";
import type { Address, Comment, Image, Order, Post, Profile, Team } from ".";
/**
* Application user account
*
* @see App\Models\User
*/
export interface User {
id: number;
/** User name formatted with first letter capitalized */
name: string;
email: string;
email_verified_at: string | null;
password: string;
options: Record<string, unknown> | null;
remember_token: string | null;
created_at: string | null;
updated_at: string | null;
role: RoleType | null;
membership_level: MembershipLevelType | null;
phone: string | null;
avatar: string | null;
bio: string | null;
settings: {
theme: "light" | "dark";
notifications: boolean;
locale: string;
} | null;
last_login_at: string | null;
last_login_ip: string | null;
}
export interface UserResource extends Omit<User, "role" | "membership_level"> {
role: AsEnum<typeof Role> | null;
membership_level: AsEnum<typeof MembershipLevel> | null;
}
export interface UserMutators {
/** User initials (e.g. "JD" for "John Doe") */
initials: string;
/** Whether the user is a premium member */
is_premium: boolean;
}
export interface UserRelations {
// Relations
profile: Profile | null;
posts: Post[];
comments: Comment[];
orders: Order[];
addresses: Address[];
teams: Team[];
owned_teams: Team[];
/** Polymorphic images (avatar gallery, etc.) */
images: Image[];
/** Get the entity's notifications. */
notifications: DatabaseNotification[];
// Counts
profile_count: number;
posts_count: number;
// ...one `_count` per relation
// Exists
profile_exists: boolean;
posts_exists: boolean;
// ...one `_exists` per relation
}
export interface UserAll extends User, UserMutators, UserRelations {}
export interface UserAllResource
extends UserResource, UserMutators, UserRelations {}Each interface holds one part of the model:
User: the columns. The enum columnsroleandmembership_leveluse the enum's{Enum}Typeunion, which is how Eloquent serializes a backed enum to JSON.settingsis an inline object type from a#[TsCasts]override.UserResource: the same columns, with each enum column typed as a resolved enum instance. See Enum-Typed Columns.UserMutators: the accessors, new-styleAttributeor old-stylegetXAttribute(). Each accessor's PHPDoc description becomes a JSDoc comment. See PHPDoc Descriptions.UserRelations: every relation, including polymorphic ones and framework relations such asnotifications, which is imported from a generatedilluminate/notificationsdirectory. Each relation also gets a_countand an_existsproperty, matching Laravel'swithCountandwithExists.UserAllandUserAllResource: the interfaces combined.UserAllis generated when the model has accessors or relations.UserAllResourceis generated whenever the model has an enum-typed property, even with no accessors or relations.
Model Templates
By default, a model is split into up to four interfaces ({Model}, {Model}Mutators, {Model}Relations and {Model}All), so a page imports only the parts it uses. The model-full template puts everything in one interface instead:
| Template | Description |
|---|---|
laravel-ts-publish::model-split | (Default) Separate interfaces for columns, mutators and relations, plus an All interface that combines them. |
laravel-ts-publish::model-full | One interface, with // Columns, // Mutators and // Relations comments between the groups. |
Set the template in your config:
// config/ts-publish.php
'models' => [
'template' => 'laravel-ts-publish::model-full',
],TIP
An accessor listed in the model's $appends array publishes in the main {Model} interface in both templates, because Laravel includes appended attributes every time it serializes the model to JSON.
To change a template's structure, see Publishing and Editing Templates.
Choosing Between Interfaces in a Form
The split template lets you compose only the pieces a page needs. This Inertia form uses the full User shape plus one relation flag:
import { useForm } from "@inertiajs/vue3";
import type { User, UserRelations } from "@js/types/data/app/models";
interface UserForm extends User, Pick<UserRelations, "profile_exists"> {
profile: UserRelations["profile"] | null;
}
const form = useForm<UserForm>({ ...user });
form.profile; // Profile | null
form.posts; // TS error: `posts` isn't part of UserFormWith model-full, you Omit every relation property you don't need, instead of picking the ones you do:
import type { User } from "@js/types/data/app/models";
interface UserForm extends Omit<
User,
"profile" | "posts" | "profile_count" | "posts_count" | "posts_exists"
> {
profile: User["profile"] | null;
}Because model-full includes every relation, each model also depends on the full interface of every model it relates to. A type error two or three relations deep then surfaces in every model that reaches it. That can help you find models whose types are incomplete, but those errors take longer to trace and fix.
Nullable Relations
Singular relations get | null from their relation type. For BelongsTo and MorphTo, the package also checks whether the foreign key columns are nullable:
| Relation Type | Strategy | Behavior |
|---|---|---|
HasOne, MorphOne, HasOneThrough | nullable | Always nullable, because the related record may not exist. |
BelongsTo | fk | Nullable only when the foreign key column is nullable in the database. |
MorphTo | morph | Nullable when either the morph type column or the morph id column is nullable. |
HasMany, HasManyThrough, BelongsToMany, MorphMany, MorphToMany | never | Never nullable. An empty relation serializes as an empty array, not null. |
For example, the User and Post relations publish like this:
export interface UserRelations {
profile: Profile | null; // HasOne: always nullable
posts: Post[]; // HasMany: never nullable
}
export interface PostRelations {
author: User; // BelongsTo: user_id is NOT NULL
category_rel: Category | null; // BelongsTo: category_id is nullable
}Disabling or Overriding the Strategy
Set models.nullable_relations to false to keep every singular relation non-nullable:
// config/ts-publish.php
'models' => [
'nullable_relations' => false,
],To change the strategy for one relation type, map its class to 'nullable', 'never', 'fk' or 'morph' in models.relation_nullability_map. This works for custom and third-party relation classes too:
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use SomePackage\Relations\BelongsToTenant;
'models' => [
'relation_nullability_map' => [
BelongsTo::class => 'nullable', // make all BelongsTo always nullable
HasOne::class => 'never', // make HasOne never nullable
BelongsToTenant::class => 'fk', // custom relation type, checked like BelongsTo
],
],The table above is the default map. A relation class that isn't in the map is always nullable, so map a custom relation class to give it a stricter strategy.
What Gets Published: Hidden Attributes, Write-Only Accessors
Not every attribute Eloquent knows about reaches the generated interface. Hidden attributes, write-only accessors and relations that share an attribute's name follow their own rules.
Hidden Attributes
$hidden attributes publish by default. Set models.exclude_hidden to true to leave them out, which matches what Laravel's toArray() and toJson() return:
// config/ts-publish.php
'models' => [
'exclude_hidden' => true,
],With the setting on, hidden columns such as password and remember_token also drop out of resource interfaces. That applies to any resource that takes the model's properties implicitly, through whole-model delegation or except(). A resource's only(['password']) still keeps a hidden column it names. See exclude_hidden and Attribute Filters on the API Resources page for the full rule.
The default is off, so upgrading the package never drops a property your frontend already reads. Turn it on once you've confirmed the frontend doesn't need those columns. The package can't see a runtime makeVisible() call, so a hidden column stays out of the model's interface even on requests that reveal it. If the frontend needs a hidden column, remove it from $hidden.
Write-Only Accessors
A write-only accessor has a setter and no getter: Attribute::set(...), or Attribute::make(set: ...). Its type comes from the first of these that applies:
- The
Gettype of its@return Attribute<Get, Set>docblock, when that type is specific and isn'tnever. - The database type of a column with the same name.
- Nothing. The accessor is left out of the interface instead of publishing as
unknown, unless a class-level@propertytag types it.
Attribute<never, string>, or Attribute<?never, string>, is the usual way to document an accessor with no getter. Its never records that there is no getter, not what reading the attribute returns, so a column behind it publishes its database type: subject: string, not subject: never.
Step 2 uses the column's database type and skips its cast. Laravel reports a column that has a mutator as cast by that mutator, so an enum, integer or boolean cast on the column isn't applied. An old-style set{Name}Attribute() method behaves the same way. To publish a more specific type, give the accessor a specific Get type, such as Attribute<Role|null, string>, which publishes RoleType | null, or add a #[TsCasts] entry.
In this Order model, one write-only accessor documents its Get type and one documents nothing:
class Order extends Model
{
/** @return Attribute<?string, string> */
protected function trackingCode(): Attribute
{
return Attribute::make(set: fn (string $value): string => strtoupper($value));
}
// No getter, no docblock generic, no backing column: left out of OrderMutators.
protected function searchIndex(): Attribute
{
return Attribute::make(set: fn (string $value): string => strtolower($value));
}
}trackingCode publishes as tracking_code: string | null in OrderMutators, and searchIndex doesn't appear. A write-only accessor on a real column, such as one that normalizes a value on save, publishes as that column in the main {Model} interface, not as a mutator.
A Relation Named Like a Column or Accessor
A relation can share its published name with a column or an accessor, such as a supervisor column beside a supervisor() relation. A model's JSON has one supervisor key, and a loaded relation takes it. The combined {Model}All interface does the same, and leaves the attribute's key out:
export interface DepotAll extends Omit<Depot, "supervisor">, DepotRelations {}Depot and DepotRelations each keep their own supervisor, so the interface you choose still says which value you have. The model-full template and the global declaration file publish only the relation's line.
A relation's _count and _exists properties follow the same rule. When a column or an accessor already has that name, such as an orders_count counter-cache column, it keeps its own type and isn't published a second time.
Model Attributes
All attributes live in the AbeTwoThree\LaravelTsPublish\Attributes namespace.
| Attribute | Target | Description |
|---|---|---|
#[TsCasts] | casts() method, $casts property, or model class | Override or add TypeScript types for columns, mutators or relations. |
#[TsType] | Custom cast class | Set the TypeScript type used wherever this cast class is applied. |
#[TsExclude] | Model class, accessor method, or relation method | Exclude a whole model, or one accessor or relation. |
#[TsCasts]
#[TsCasts] takes an array that maps property names to a TypeScript type. A value is either a type string, or an array: ['type' => ..., 'import' => ...] for a type you import from your own files, and ['type' => ..., 'optional' => true] to mark the property optional with a ?:
use AbeTwoThree\LaravelTsPublish\Attributes\TsCasts;
class User extends Model
{
#[TsCasts([
'metadata' => '{label: string, value: string}[]',
'settings' => 'Record<string, unknown>',
'dimensions' => ['type' => 'ProductDimensions', 'import' => '@js/types/product'],
])]
public function casts(): array
{
return [
'metadata' => 'array',
'settings' => 'array',
'dimensions' => 'array',
];
}
}The published interface uses those types and imports ProductDimensions:
import type { ProductDimensions } from "@js/types/product";
export interface User {
metadata: { label: string; value: string }[];
settings: Record<string, unknown>;
dimensions: ProductDimensions;
}You can put #[TsCasts] on the casts() method, on the $casts property, or on the model class. All three take the same array.
TIP
Put #[TsCasts] on casts() or $casts, so the TypeScript type sits next to the PHP cast. Put it on the class when you override a mutator or a relation. It's also the right place for a model you extend from the vendor directory, since you can override its relation and mutator types without touching the vendor's casts.
#[TsType]
For a custom cast class you use on several models, put #[TsType] on the cast class once, instead of repeating #[TsCasts] everywhere you use it:
use AbeTwoThree\LaravelTsPublish\Attributes\TsType;
#[TsType(['type' => 'ProductDimensions', 'import' => '@js/types/product'])]
class ProductDimensionsCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes) { /* ... */ }
}
class Product extends Model
{
public function casts(): array
{
return ['dimensions' => ProductDimensionsCast::class];
}
}Every column that uses the cast publishes with that type:
import type { ProductDimensions } from "@js/types/product";
export interface Product {
dimensions: ProductDimensions;
}#[TsType] also takes a plain string, such as #[TsType('{width: number, height: number}')], when the type needs no import. Without #[TsType], a custom cast publishes the return type of its get() method. A cast class that implements SerializesCastableAttributes, including one a Castable value object names, publishes the return type of its serialize() method instead, because that's what Laravel sends when it converts the model to JSON.
When get()'s native type is missing or vague, such as a bare array, the @return docblock sets the type, so @return list<LegDto> publishes a list of LegDto's public properties, the shape json_encode() writes. A @return that names a model, such as list<User>, or another class that doesn't publish as an inline shape keeps unknown[]. A get() that declares neither publishes unknown.
Laravel 13 Model Attributes
Laravel 13 added native class attributes for Eloquent models (Illuminate\Database\Eloquent\Attributes) and API resources (Illuminate\Http\Resources\Attributes). They replace property-based conventions, such as #[Table] in place of protected $table. These are Laravel's attributes, not this package's, so you don't import anything from AbeTwoThree\LaravelTsPublish\Attributes to use them. The package honors the ones that change a model's serialized shape, with no configuration:
| Attribute | Honored? | Notes |
|---|---|---|
#[Table('...')] | Yes | Selects the table whose columns are published, the same as protected $table. |
#[Hidden(['col'])] | Yes | Marks columns hidden, the same as protected $hidden. See Hidden Attributes. |
#[Visible(['col'])] | Yes | An allowlist, the same as protected $visible: every column it doesn't list becomes hidden. With models.exclude_hidden on, list every column you want published, or most of the model disappears from the interface. |
#[Appends(['accessor'])] | Yes | Adds accessors to the published set, the same as protected $appends. |
#[Connection('name')] | Yes | Selects the database connection whose schema the columns come from, the same as protected $connection. |
#[Collects(SomeResource::class)] | Yes | Sets the resource a collection collects. See Resource Collections. |
#[UseResource(...)], #[UseResourceCollection(...)] | Yes | Link a model to its resource. See How the Backing Model Is Resolved. Available since Laravel 12.29. |
#[PreserveKeys] | Yes | Types the collection's data as Record<string, R> instead of R[], the keyed JSON object Laravel returns. public $preserveKeys = true; does the same. See Key-Preserving Collections on the API Resources page. |
#[RouteKey('slug')] | Yes | A model-bound route argument types _routeKey from the attribute's key, even when the model overrides none of getRouteKeyName(), getKeyName() or $primaryKey. See Model Binding on the Routing page. |
Everything else (#[DateFormat], #[WithoutTimestamps], #[WithoutIncrementing], #[Fillable], #[Guarded], #[Unguarded], #[Scope], #[ScopedBy], #[ObservedBy], #[Boot], #[Initialize], #[Touches], #[CollectedBy], #[UseEloquentBuilder], #[UseFactory], #[UsePolicy]) | N/A | These change querying, events, mass assignment or factories, not the serialized shape, so they don't affect the published types. |
Older Laravel Versions
#[Table], #[Hidden], #[Visible], #[Appends], #[Connection], #[Collects] and #[PreserveKeys] require Laravel 13. #[RouteKey] requires Laravel 13.21. #[UseResource] and #[UseResourceCollection] require Laravel 12.29. On an older version, using one isn't an error: the model loads normally and the attribute is ignored. Nothing tells you that #[Table] didn't take effect, so run a Laravel version that supports each attribute you rely on.
Typing Attributes Without #[TsCasts]
Most columns and accessors type themselves. When one publishes unknown or unknown[], reach for these annotations before #[TsCasts], in this order:
- A parameterized
Attribute<>,@returnor@phpstan-returndocblock on the accessor, generics included, such asAttribute<Collection<int, LineItem>, never>. - A class-level
@propertyor@property-readtag, including one that uses a@phpstan-typeor@phpstan-import-typealias. - A
@return MorphTo<A|B, $this>generic on amorphTo()relation.
PHPStan and Larastan read all of them, so the annotation that fixes your TypeScript is also checked by static analysis, and the package reads them with no configuration. #[TsCasts] is still the right tool when a shape's keys are built at runtime, or when the type belongs to the frontend and needs its own import. The Annotation Checklist lists each case by symptom.
Accessor Getter Bodies
When an accessor's getter has no specific return type, and its @return Attribute<Get, Set> docblock is missing or vague, the package reads what the getter returns. An accessor that would otherwise publish unknown[] publishes the shape it builds:
class Release extends Model
{
protected function versionData(): Attribute
{
return Attribute::get(fn () => ['major' => $this->major, 'minor' => $this->minor]);
}
protected function tagList(): Attribute
{
return Attribute::get(fn (): array => collect(explode(',', $this->tags_csv))
->map(fn ($tag) => ['name' => $tag])
->values()
->all());
}
protected function emptyList(): Attribute
{
return Attribute::get(fn (): array => []);
}
}The first two accessors publish the shapes their getters return. The empty list keeps the unknown[] of its : array signature, because an empty [] says nothing about its elements:
export interface ReleaseMutators {
version_data: { major: number; minor: number };
tag_list: { name: string }[];
empty_list: unknown[];
// …
}The package reads Attribute::make(), Attribute::get(), new Attribute(get: ...) and old-style get{Name}Attribute() bodies. An accessor declared in a trait is read against the model that uses the trait. A method the getter calls is read the same way: channelOptions() returns static::channelLabels(), whose signature is a bare : array, and still publishes { "1": string; "2": string }. A getter that returns an untyped property is typed from the property's default value, so channels(), which returns protected $allowedChannels, publishes string[].
A specific return type or Attribute<Get, Set> generic is always read first, so the getter body never overrides an accurate annotation. When the getter can't be typed, the accessor keeps the type from its signature or docblock, or publishes unknown if it has neither. That happens when the getter:
- Returns an empty
[]literal, likeempty_listabove - Builds its keys in a loop, such as
$totals['k'.$index] = $index - Calls something whose return type can't be determined
- Reads another accessor that reads it back, in which case both publish
unknown - Can only be typed as
null, because one side of a??or a ternary couldn't be typed - Names two different classes or enums that share a short name, such as
$this->status ?? $this->author?->statuswhen eachstatusis a differentStatusenum - Returns an API resource
The null case keeps a getter from publishing a type that's plainly wrong: fn ($value, array $attributes) => $attributes['title'] ?? null publishes unknown, not null, because its real value is the title.
Typing array Casts With @property
A column cast to 'array', or to any cast with no more specific type, publishes as unknown[]. Instead of #[TsCasts], add a class-level @property or @property-read tag with the real shape. It's the same tag PHPStan and Larastan read:
/**
* @property array<int, string>|null $to
* @property array<string, string>|null $headers
*/
class Message extends Model { ... }$to and $headers publish as string[] | null and Record<string, string> | null instead of unknown[] | null, and PHPStan and Larastan type the same properties.
A tag can also spell a tuple, an object shape, quoted or string literal keys, or a nullable shape inside another. list{int, string} publishes the tuple [number, string], object{a: int} publishes { a: number }, array<'a'|'b', int> publishes { a?: number; b?: number }, and array{a: ?array{b: int}} publishes { a: { b: number } | null }. A callable inside a shape publishes unknown.
On a nullable column, the tag's type gains | null unless it already holds null at its top level. A null inside a shape doesn't count, so @property array{a: ?int} $meta publishes { a: number | null } | null.
A tag applies only when the column's own type is vague. It never overrides a specific type, such as an accessor's return type, an enum cast or a custom CastsAttributes class. A subclass's tag wins over its parent's. @property-write tags are ignored, because they describe the setter.
A tag that is still partly vague is used when it's more specific than the type it replaces. @property array<string, mixed>|null $settings turns a plain 'array' cast into Record<string, unknown> | null: the type still names unknown, but it beats unknown[] | null. A tag that is exactly as vague as the original is ignored: unknown, unknown[], object, or the unknown[] | Record<string, unknown> of a bare collection.
The package also reads @property tags on the traits the model and its parents use, and on the traits those traits use. A trait that supplies an accessor can document it that way. The $-less form some packages use, @property string[] labels, works as long as nothing follows the property name.
Typing JSON Columns With @phpstan-type Aliases
For a shape that deserves a name, define it once as a @phpstan-type on the DTO that owns it, then import it into the model with @phpstan-import-type:
/** @phpstan-type PresetConfig array{filters?: array<string, mixed>, sorts?: list<string>} */
final readonly class PresetDto { ... }
/**
* @phpstan-import-type PresetConfig from PresetDto
* @property PresetConfig|null $config
*/
class Preset extends Model { ... }$config publishes as { filters?: Record<string, unknown>; sorts?: string[] } | null. The alias expands inline, so PresetDto isn't imported, optional keys keep their ?, and PHPStan checks the same alias. The package also reads @phpstan-import-type ... as Alias, @psalm-type and @psalm-import-type. An alias also expands inside a list or a shape, as in list<PresetConfig>|null or array{preset: PresetConfig}, and in a promoted constructor property's @var. An alias can use another imported alias, and an import cycle publishes unknown instead of stalling the run. For a shape that's already worth documenting for static analysis, prefer an alias over #[TsCasts].
Nullable and Intersection Docblock Types
A nullable alias keeps its | null. Here it's written with a trailing |null:
/**
* @phpstan-import-type GridConfig from GridConfigDto
* @phpstan-import-type GridPreset from GridConfigDto
*
* @property GridConfig|null $grid_config
* @property GridPreset|null $grid_preset
*/
class Team extends Model { ... }Both aliases expand, and both keep | null:
export interface Team {
grid_config: {
filters?: Record<string, unknown>;
sorts?: string[];
columns?: string[];
} | null;
grid_preset: { name: string; locked?: boolean } | null;
// …
}A leading ? works the same way, on a generic and on an alias, so @property ?GridPreset $grid_preset also keeps its | null. The same goes for an accessor's @return docblock:
/** @return Attribute<?array<int, int>, never> */
protected function stateIds(): Attribute
/** @return Attribute<?FlagValue, never> */ // FlagValue is a @phpstan-type alias
protected function flagDefault(): AttributeEach type publishes with | null:
export interface OrderMutators {
state_ids: number[] | null;
flag_default: boolean | number | string | null;
// …
}A docblock intersection resolves each member and joins them with &:
/** @return Attribute<Collection<int, User&object{pivot: TaskAssignment}>, never> */
protected function assignedUsers(): AttributeUser keeps its import, but pivot publishes as unknown:
export interface ProjectMutators {
assigned_users: (User & { pivot: unknown })[];
// …
}A class named inside an object{...} shape always publishes as unknown, which is why TaskAssignment is lost here. A member that can't be resolved is dropped from the intersection, instead of turning the whole type into unknown, since A & B is assignable to A.
Trait @template Bindings
A generic trait's @template parameter is bound by the @use tag on the class that uses the trait, so one trait can give many models a correctly typed accessor:
/** @template TChild of Model */
trait AggregatesChildren
{
/** @return Attribute<EloquentCollection<int, TChild>, never> */
protected function childItems(): Attribute { ... }
}
class Project extends Model
{
/** @use AggregatesChildren<Comment> */
use AggregatesChildren;
}TChild becomes Comment for this model:
export interface ProjectMutators {
child_items: Comment[];
// …
}PHPStan reads the @use tag for the same purpose, so static analysis checks the binding too.
Typing Castable-With-Arguments Casts
Laravel's built-in Castable classes carry their configuration after a colon in the cast string. AsEnumCollection::of(DayOfWeek::class), AsCollection::of(...) and AsCollection::using(...) all build a "ClassName:arg1,arg2" cast string. The package types these with no extra configuration:
protected function casts(): array
{
return [
'week_days' => AsEnumCollection::of(DayOfWeek::class),
'grid_configs' => AsCollection::of(GridConfigDto::class),
];
}Each cast publishes the mapped class's type as a list:
AsEnumCollection::of($enum): the enum's type with[], such asDayOfWeekType[]. The enum is imported the same way as for an enum-cast column.AsCollection::of($map)andAsCollection::using($collection, $map): the mapped class's shape with[]. AnArrayableDTO with a documentedtoArray()shape inlines as an object array, such as{ label: string; config: Record<string, unknown> }[], and a mapped enum publishes the same way asAsEnumCollection. When the mapped class has no shape the package can read, or the cast is a bareAsCollection, the column staysunknown[].- Any other
CastableorCastsAttributesclass with arguments: a custom cast orAsEncryptedCollection, for example, publishes as if the arguments weren't there.
Typing morphTo Relations
By default, a morphTo() relation is typed as the union of every published model that declares a morphOne() or morphMany() pointing back at it. A relation that returns a custom subclass of MorphOne or MorphMany counts too. That lookup can only ever build the full union, never narrow it. To type the relation directly, add a @return MorphTo<A|B, $this> docblock to the relation method. PHPStan checks the same docblock, and you don't need #[TsCasts]:
class Activity extends Model
{
/** @return MorphTo<User, $this> */
public function causer(): MorphTo
{
return $this->morphTo();
}
}causer publishes as User | null, even though no model declares a relation pointing back at Activity. With neither a docblock nor an inverse relation, a morphTo() publishes as unknown.
The second generic argument, $this, is Laravel's convention for the child model and is ignored. A generic that names the base Model class or an abstract class is ignored too, and the relation falls back to the union of its inverse relations. That covers @phpstan-return MorphTo<Model, $this>, which is what Larastan expects when a relation's targets aren't known up front, and which would otherwise publish a Model type that nothing can import. Each morphTo() relation resolves on its own, so two differently named morphTo relations on one model get separate types.
Parents Found Through a Subclass or a Custom Pivot
The union also picks up two kinds of parent that need no docblock.
A parent can point its relation at a subclass of the model that declares the morphTo(). Venue::reviews() returns morphMany(VenueReview::class, 'reviewable'), where VenueReview extends Review and only Review declares reviewable(), so Venue joins Review's union:
export interface ReviewRelations {
reviewable: Artist | Venue;
// …
}
export interface VenueReviewRelations {
reviewable: Venue;
// …
}A subclass never inherits a sibling's parents, so VenueReview::reviewable stays Venue, and Artist never appears there. The union only grows upward. A parent declared against the base Review is never added to a subclass, because a row written through that relation stores Review's morph value, not the subclass's.
The second kind is a morphToMany(...)->using(Pivot::class) whose custom pivot declares its own morphTo(). The pivot row's morph column names the parent directly, so the pivot's relation resolves like any other:
class Venue extends Model
{
/** Labels attached via the custom Labelable pivot, which itself carries the morphTo back */
public function labels(): MorphToMany
{
return $this->morphToMany(Label::class, 'labelable')->using(Labelable::class);
}
}With Artist declaring the same relation, the pivot's labelable covers both parents:
export interface LabelableRelations {
labelable: Artist | Venue;
// …
}A morphToMany() without ->using() adds no parent, and neither does morphedByMany(). That's the inverse side of the relation, so counting it would record the wrong parent.
DTO-Typed Accessors and Casts
An Arrayable DTO whose toArray() has no @return array{...} shape publishes the shape of its typed public properties, promoted constructor properties included, instead of unknown[]:
final readonly class ShippingOptions implements Arrayable
{
public function __construct(
public string $carrier,
public bool $insured,
public ?string $trackingUrl = null,
) {}
/** @return array<string, bool|string|null> */
public function toArray(): array
{
return (array) $this;
}
}An accessor or cast that returns this DTO publishes as { carrier: string; insured: boolean; trackingUrl: string | null }. Nullable properties keep their | null. Private, protected and static properties are left out, because (array) $this doesn't include them. A property typed as a class or an enum, such as a model, publishes as unknown, because the inline shape can't import it.
Optional is separate from nullable. A property that is neither promoted nor given a default value publishes as an optional key. Add public string $summary; to the class body above, assigned in the constructor, and it publishes as summary?: string. PHP leaves an unassigned typed property out of (array) $this, and the package can't tell whether the constructor always assigns it. To keep the key required, promote the property, or give it a default value on a DTO that isn't readonly. A @return array{...} docblock on toArray() always wins, so add one when the properties don't tell the whole story.
This only applies to Arrayable. A JsonSerializable DTO is typed from a @return array{...} docblock on jsonSerialize() and never from its properties, because jsonSerialize() can return anything. A jsonSerialize() that declares string or ?string, in its signature or its @return, publishes string or string | null. One whose @return is \DateTime publishes { date: string; timezone_type: number; timezone: string }, the date object json_encode() writes. Without any of these, the DTO isn't inlined, and the property publishes by other rules, such as the DTO's class name.
json_encode() ignores __toString(), so a class with only __toString() doesn't publish string. It publishes what json_encode() writes: its public properties as a shape, with unknown for one that has no type, or Record<string, never>, the {}, when it has no public properties. A class that allows dynamic properties, or extends ArrayObject, publishes Record<string, unknown>, and one whose jsonSerialize() returns an array with no @return array{...} shape publishes unknown[] | Record<string, unknown>. An accessor whose getter returns a first-class callable, such as Attribute::get(fn () => strlen(...)), publishes Record<string, never> too, because Laravel sends the Closure as {}. Call the function in the getter instead.
Annotation Checklist
This table indexes the annotations above by symptom, plus two cases from API Resources. None of them needs #[TsCasts], and PHPStan and Larastan read them all, so the annotation that fixes the TypeScript type is also checked by static analysis:
Still publishing unknown? | Add this | Result |
|---|---|---|
Attribute<Collection, never> publishes unknown[], and the getter builds the value in a loop | Parameterize the generic: Attribute<Collection<int, LineItem>, never>, or array{...} for a fixed shape | The element type (LineItem[]), imported automatically. Keys built in a loop can't be read from the getter body, so the generic is the fix |
A bare 'array' or 'collection' cast with no shape anywhere else | A class-level @property or @property-read tag, such as @property array<string, mixed>|null $settings | Record<string, unknown> | null, or the tag's more specific type, instead of unknown[] | null. See Typing array Casts With @property |
| A JSON shape you want to name once and reuse | @phpstan-type Name array{...} on the class that owns it, then @phpstan-import-type Name from ThatClass and @property Name $prop on the model | The shape, expanded inline and checked by PHPStan, with no import of the DTO. See Typing JSON Columns With @phpstan-type Aliases |
An AsEnumCollection or AsCollection cast with no argument publishes unknown[] | Pass the mapped class: AsEnumCollection::of(Status::class), AsCollection::of(LineItemDto::class) | The element's type, an enum or a DTO shape, as a list. See Typing Castable-With-Arguments Casts |
A morphTo() publishes unknown because no model declares the matching morphOne() or morphMany() | @return MorphTo<A|B, $this> on the relation method | The union, with every member imported. See Typing morphTo Relations |
A morphTo() whose parents point at a subclass, or reach it through a ->using() pivot | Nothing. The union includes both automatically | The parent union. See Parents Found Through a Subclass or a Custom Pivot |
An Arrayable DTO accessor or cast publishes unknown[] | Nothing. Typed public properties, promoted constructor properties included, are read automatically when toArray() has no @return array{...} shape | The DTO's property shape instead of unknown[]. See DTO-Typed Accessors and Casts |
$this->relation->only([...]) or ->except([...]) loses the related model's #[TsCasts] or @property types | Nothing. This is automatic when the relation resolves to a single model and every filtered key is a database column | Pick<Model, 'a' | 'b'>, which reuses the model's own interface. except() picks every other column. See Relation Filters on the API Resources page |
An accessor or relation is missing from an inlined $this->relation->except([...]) | Name it: switch that key to only([...]), or give it its own entry in toArray() | The key comes back. An inlined except() expands to database columns only, which matches what Model::except() returns at runtime. See Relation Filters on the API Resources page |
An accessor publishes unknown[] from a vague Attribute<array, never> or a bare : array getter | Nothing. The getter body is read when the signature and the docblock are both vague | The shape the getter returns. See Accessor Getter Bodies |
A query-selected attribute (selectRaw('… as rank')) has no column, cast or accessor behind it | A class-level @property or @property-read tag naming it | The tag's type wherever a resource or another published type reads the attribute. The tag doesn't add the attribute to the model's own interface, which lists only what the schema and casts declare |
A nullable docblock generic (?Alias, Attribute<?array<int, int>, never>) publishes unknown | Nothing. A leading ? is read as | null | The type with | null. See Nullable and Intersection Docblock Types |
If a property still publishes unknown, open an issue on the package's GitHub issue tracker with the PHP and the generated TypeScript.
PHPDoc Descriptions
The package reads doc blocks and turns their descriptions into JSDoc comments:
| Location | Source | JSDoc Placement |
|---|---|---|
| Model class | Doc block above the class | Above the export interface declaration |
| Columns | Doc block above the column's accessor method | Above the column property |
| Mutators | Doc block above the mutator's accessor method | Above the mutator property |
| Relations | Doc block above the relation method | Above the relation property |
For columns and mutators, the package checks the new-style accessor (protected function name(): Attribute) before the old-style one (public function getNameAttribute()). Tag lines such as @param, @return and @phpstan-type are removed, so only the prose description carries over. These doc blocks become the JSDoc comments in the Anatomy of a Generated Model example:
/** Application user account */
class User extends Model
{
/** User initials (e.g. "JD" for "John Doe") */
protected function initials(): Attribute
{
return Attribute::make(get: fn (): string => /* ... */);
}
/** Polymorphic images (avatar gallery, etc.) */
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}Enum-Typed Columns ({Model}Resource)
A column or accessor typed as a backed or unit enum publishes in two ways:
- The main interface:
Useruses the enum's{Enum}Typeunion, which is how Laravel serializes the enum when it converts a model to JSON. - A parallel
Resourceinterface:{Model}Resourcetypes the same property asAsEnum<typeof Enum>, and the split template adds{Model}MutatorsResourcefor accessors and{Model}AllResource.AsEnumis the shape you get once the raw value is a full enum instance, either fromStatus.from(user.status)or from an API resource that serialized the enum withEnumResource.
A property is rewritten only when its type is a single enum or a list of one, either optionally | null. A list becomes AsEnum<typeof Enum>[]. A shape, or a union with other types, keeps its own type.
This helper resolves the raw role value into the type UserResource declares:
import { Role } from "@js/types/data/app/enums";
import type { User, UserResource } from "@js/types/data/app/models";
function displayRole(user: User) {
const resolved: UserResource["role"] = user.role
? Role.from(user.role)
: null;
// resolved?.name, resolved?.value, etc.: a full enum instance, not only the raw value
}WARNING
The Resource interfaces, and the AsEnum import, are generated only when enums.use_tolki_package is true, which is the default. Set it to false and enum columns use {Enum}Type everywhere.
Timestamps as Date Objects
Date and timestamp columns (date, datetime, timestamp and their immutable variants) publish as string by default. Set timestamps_as_date to true to publish them as Date:
// config/ts-publish.php
'timestamps_as_date' => true,The setting changes every date column:
| Config Value | Generated Type |
|---|---|
false (default) | created_at: string |
true | created_at: Date |
A timestamp cast is the exception. It publishes number under either setting, as Dates & Times explains.
Custom TypeScript Type Mappings
To change how a type publishes everywhere, add it to custom_ts_mappings. Keys are matched case-insensitively, and your entries take precedence over the built-in map, including the cast and database driver rules below:
// config/ts-publish.php
'custom_ts_mappings' => [
'binary' => 'Blob',
'json' => 'Record<string, unknown>', // overrides the default 'object' mapping
'money' => 'number', // keeps money columns a number on every driver
],To see which entry a cast, a column or a date follows, read custom_ts_mappings in the Configuration Reference.
TIP
To change one property instead of every column of a type, use #[TsCasts] or #[TsType].
Type Mapping Reference
The package maps database column types and casts to these TypeScript types by default.
Numbers
bigint, decimal, double, double precision, float, integer, int, numeric, number, mediumint, smallint, year, real, money, smallmoney, serial, bigserial, smallserial → number. An uncast decimal, numeric or PostgreSQL money column can publish string instead, as described below.
A bare tinyint, as created by tinyInteger() on MySQL and SQL Server, is also number. Only tinyint(1), the type Laravel's boolean() column has on MySQL and SQLite, means boolean. See Booleans.
A decimal:N cast, such as decimal:2, publishes string, because Laravel returns a decimal cast as a string on every database.
A decimal, numeric or PostgreSQL money column with no cast follows the driver of the connection you publish on. It publishes string on MySQL, MariaDB and PostgreSQL, and number on SQLite and SQL Server. Publish against the database driver your app runs in production, or cast the column decimal:N to publish string on every driver. To keep another type on every driver, map the column type in custom_ts_mappings, such as 'decimal' => 'number' on MySQL (this also covers decimal:N casts) or 'numeric' => 'number' on PostgreSQL.
On SQL Server, cast the column decimal:N, because the pdo_sqlsrv driver returns a decimal as a string while the package publishes number.
Booleans
bool, boolean, bit, tinyint(1) → boolean
Strings
char, character, enum, longtext, mediumtext, string, text, varchar, encrypted, uuid, guid, hashed, time, timetz, timestamptz, numeric-string → string
Sized, binary and legacy database types publish the same way: tinytext, binary, varbinary, blob, bytea, tinyblob, mediumblob, longblob, nvarchar, nchar, ntext, xml, interval, uniqueidentifier and datetimeoffset → string. set(…) is also a string, not an array, because MySQL returns a matched SET as a comma-separated string. So are the Postgres and MySQL network and full-text types: inet, cidr, macaddr, macaddr8 and tsvector.
Arrays & Objects
| Cast | TypeScript Type |
|---|---|
array, collection, iterable | unknown[] |
AsCollection, AsEncryptedCollection, AsEnumCollection (bare) | unknown[] |
AsArrayObject, AsEncryptedArrayObject, AsEnumArrayObject | unknown[] | Record<string, unknown> |
json, jsonb, object | object |
Illuminate\Database\Eloquent\Collection | Record<string, unknown> |
Illuminate\Support\Collection | unknown[] | Record<string, unknown> |
Illuminate\Database\Eloquent\Casts\AsFluent | object |
The three As*ArrayObject casts hold an ArrayObject, which serializes a list as a JSON array and anything else as an object, so the type allows both shapes. Check Array.isArray() on the value before you treat it as an object, or pin its shape with #[TsCasts].
The unknown[] collection row is the bare form. AsEnumCollection::of(...), AsCollection::of(...) and AsCollection::using(...) publish the mapped element's type instead. See Typing Castable-With-Arguments Casts.
A parameterized docblock generic in @return, @property or Attribute<> publishes a more specific type, based on its key type. Every container behaves the same way for a given key type:
| Key type | Emitted | Containers |
|---|---|---|
int, or omitted | X[] | list<X>, array<…>, iterable<…>, Collection<…> |
string | Record<string, X> | array<…>, iterable<…>, Collection<…> |
array-key, mixed | X[] | Record<string, X> | array<…>, iterable<…>, Collection<…> |
list<X> has no key type, so it always publishes as X[]. A container with no generic at all, such as a bare Collection, uses the Arrays & Objects table above instead.
A collection chain on a relation, such as ->sortBy(), ->pluck($value, $key) or ->take(), publishes as X[] | Record<string, X> whatever its declared type, because those methods can leave the keys out of order. It narrows to X[] when the chain ends with keys that count up from 0. A trailing ->values() does that, and so does a ->take() from the front of a collection whose keys are already sequential.
Dates & Times
date, immutable_date, datetime, immutable_datetime, immutable_custom_datetime, a timestamp column, datetime2, smalldatetime, and Carbon, CarbonImmutable or Illuminate\Support\Carbon casts all follow timestamps_as_date → string (default) or Date. datetime2 is what SQL Server's dateTime($precision) and timestamp($precision) create when you give a precision. It's the same kind of column as a bare datetime, so it follows the same setting, and smalldatetime does too.
A timestamp cast publishes number under either setting, because Laravel serializes it as a Unix timestamp, such as 1767225600. A timestamp column without that cast, such as created_at, keeps the date type.
An old-style accessor, get{Name}Attribute(), that returns a plain PHP DateTime or DateTimeImmutable publishes { date: string; timezone_type: number; timezone: string }. Laravel sends that object as it is, and those fields are what json_encode() writes for it. A new-style Attribute accessor or a cast that returns one keeps the date type, because Laravel formats it as a date string first. A Carbon date keeps the date type from either kind of accessor.
Other
| Cast | TypeScript Type |
|---|---|
AsStringable, AsUri, AsBinary | string |
null | null |
mixed | unknown |
never (PHPStan) | never |
void (PHPStan) | void |
true / false (PHPStan) | true / false |
array-key (PHPStan) | string | number |
scalar (PHPStan) | string | number | boolean |
geometry, geography | unknown |
vector | number[] |
A spatial column's JSON shape depends on how your app reads it. Raw WKB is a binary string, and ST_AsGeoJSON() returns an object, so the package publishes unknown instead of guessing. MySQL's geometry(subtype: '...') reports the subtype as the column type, so point, linestring, polygon, geometrycollection, multipoint, multilinestring and multipolygon all publish as unknown for the same reason. A vector column comes from pgvector or MySQL 9, which both serialize it as a JSON array of floats.
Filtering & Excluding Models
Models use the same include and exclude settings as enums and resources:
// config/ts-publish.php
'models' => [
'included' => [App\Models\User::class], // only these (empty = all)
'excluded' => [App\Models\Pivot::class], // never publish these
'additional_directories' => ['modules/Blog/Models'],
],A relation that points at a model outside included, or at an excluded model, is left out of the relations interface, and a morphTo() union drops those models too. A model outside your directories that nothing excludes is published instead. See Related Models Outside Your Directories.
#[TsExclude] on the model class excludes the whole model. On an accessor or relation method, it excludes only that property:
use AbeTwoThree\LaravelTsPublish\Attributes\TsExclude;
class User extends Model
{
#[TsExclude]
protected function secretToken(): Attribute
{
return Attribute::make(get: fn (): string => 'hidden');
}
#[TsExclude]
public function auditLogs(): HasMany
{
return $this->hasMany(AuditLog::class);
}
}See Excluding Content for how #[TsExclude] works across models, enums, resources and routes.
The model metadata feature inherits these three settings unless you set the matching model_metadata.* key.
Related Models Outside Your Directories
A relation can point at a model the package was never told to publish, such as a vendor package's model, or one in a directory that isn't in additional_directories. The package publishes that model too, along with the models it relates to in turn, so the relation's import resolves. The Notifiable trait is the common case: it relates User to Laravel's DatabaseNotification, which is published to illuminate/notifications/database-notification.ts with no config.
A related model is published this way only when:
- it isn't in
excluded, and it's inincludedwhen you set that list - its class doesn't carry
#[TsExclude] - its table or view exists
Otherwise the relation is left out, like a relation to an excluded model. In an app that never created the notifications table, UserRelations has no notifications property.
The package reads each relation on a blank model. A relation that can't be read that way, such as one that reads another model's attribute, is left out of its model, and ts:publish names it in a warning. A related model the package can't read at all is left out with a warning too.
A model published this way gets its interfaces and nothing else. It has no model metadata file, and the Vite plugin doesn't republish it when its file changes. Add its class or directory to additional_directories to get both.
Casing
models.relationship_case sets the casing of relation names: 'snake' (default), 'camel' or 'pascal'. The _count and _exists suffixes are added to the cased name as they are:
| Config Value | Relation (hasMany(Post::class)) | Count | Exists |
|---|---|---|---|
'snake' | owned_teams: Team[] | owned_teams_count | owned_teams_exists |
'camel' | ownedTeams: Team[] | ownedTeams_count | ownedTeams_exists |
'pascal' | OwnedTeams: Team[] | OwnedTeams_count | OwnedTeams_exists |
Configuration Reference
The Configuration Reference lists every models.* config key, including the pipeline class overrides for advanced customization.