Skip to content

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\User is written to app/models/user.ts, following the model's namespace. Each namespace directory also gets an index.ts barrel 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 in casts() 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 as unknown[], a class-level @property tag refines it. The Type Mapping Reference lists the default types.
  • Accessors: new-style Attribute accessors and old-style get{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 _count and an _exists property for it.
  • Metadata companions: an opt-in feature writes a runtime {model}_meta.ts file 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:

text
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:

typescript
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 columns role and membership_level use the enum's {Enum}Type union, which is how Eloquent serializes a backed enum to JSON. settings is 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-style Attribute or old-style getXAttribute(). Each accessor's PHPDoc description becomes a JSDoc comment. See PHPDoc Descriptions.
  • UserRelations: every relation, including polymorphic ones and framework relations such as notifications, which is imported from a generated illuminate/notifications directory. Each relation also gets a _count and an _exists property, matching Laravel's withCount and withExists.
  • UserAll and UserAllResource: the interfaces combined. UserAll is generated when the model has accessors or relations. UserAllResource is 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:

TemplateDescription
laravel-ts-publish::model-split(Default) Separate interfaces for columns, mutators and relations, plus an All interface that combines them.
laravel-ts-publish::model-fullOne interface, with // Columns, // Mutators and // Relations comments between the groups.

Set the template in your config:

php
// 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:

typescript
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 UserForm

With model-full, you Omit every relation property you don't need, instead of picking the ones you do:

typescript
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 TypeStrategyBehavior
HasOne, MorphOne, HasOneThroughnullableAlways nullable, because the related record may not exist.
BelongsTofkNullable only when the foreign key column is nullable in the database.
MorphTomorphNullable when either the morph type column or the morph id column is nullable.
HasMany, HasManyThrough, BelongsToMany, MorphMany, MorphToManyneverNever nullable. An empty relation serializes as an empty array, not null.

For example, the User and Post relations publish like this:

typescript
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:

php
// 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:

php
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:

php
// 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:

  1. The Get type of its @return Attribute<Get, Set> docblock, when that type is specific and isn't never.
  2. The database type of a column with the same name.
  3. Nothing. The accessor is left out of the interface instead of publishing as unknown, unless a class-level @property tag 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:

php
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:

typescript
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.

AttributeTargetDescription
#[TsCasts]casts() method, $casts property, or model classOverride or add TypeScript types for columns, mutators or relations.
#[TsType]Custom cast classSet the TypeScript type used wherever this cast class is applied.
#[TsExclude]Model class, accessor method, or relation methodExclude 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 ?:

php
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:

typescript
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:

php
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:

typescript
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:

AttributeHonored?Notes
#[Table('...')]YesSelects the table whose columns are published, the same as protected $table.
#[Hidden(['col'])]YesMarks columns hidden, the same as protected $hidden. See Hidden Attributes.
#[Visible(['col'])]YesAn 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'])]YesAdds accessors to the published set, the same as protected $appends.
#[Connection('name')]YesSelects the database connection whose schema the columns come from, the same as protected $connection.
#[Collects(SomeResource::class)]YesSets the resource a collection collects. See Resource Collections.
#[UseResource(...)], #[UseResourceCollection(...)]YesLink a model to its resource. See How the Backing Model Is Resolved. Available since Laravel 12.29.
#[PreserveKeys]YesTypes 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')]YesA 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/AThese 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:

  1. A parameterized Attribute<>, @return or @phpstan-return docblock on the accessor, generics included, such as Attribute<Collection<int, LineItem>, never>.
  2. A class-level @property or @property-read tag, including one that uses a @phpstan-type or @phpstan-import-type alias.
  3. A @return MorphTo<A|B, $this> generic on a morphTo() 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:

php
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:

typescript
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, like empty_list above
  • 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?->status when each status is a different Status enum
  • 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:

php
/**
 * @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:

php
/** @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:

php
/**
 * @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:

typescript
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:

php
/** @return Attribute<?array<int, int>, never> */
protected function stateIds(): Attribute

/** @return Attribute<?FlagValue, never> */   // FlagValue is a @phpstan-type alias
protected function flagDefault(): Attribute

Each type publishes with | null:

typescript
export interface OrderMutators {
  state_ids: number[] | null;
  flag_default: boolean | number | string | null;
  // …
}

A docblock intersection resolves each member and joins them with &:

php
/** @return Attribute<Collection<int, User&object{pivot: TaskAssignment}>, never> */
protected function assignedUsers(): Attribute

User keeps its import, but pivot publishes as unknown:

typescript
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:

php
/** @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:

typescript
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:

php
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 as DayOfWeekType[]. The enum is imported the same way as for an enum-cast column.
  • AsCollection::of($map) and AsCollection::using($collection, $map): the mapped class's shape with []. An Arrayable DTO with a documented toArray() shape inlines as an object array, such as { label: string; config: Record<string, unknown> }[], and a mapped enum publishes the same way as AsEnumCollection. When the mapped class has no shape the package can read, or the cast is a bare AsCollection, the column stays unknown[].
  • Any other Castable or CastsAttributes class with arguments: a custom cast or AsEncryptedCollection, 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]:

php
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:

typescript
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:

php
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:

typescript
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[]:

php
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 thisResult
Attribute<Collection, never> publishes unknown[], and the getter builds the value in a loopParameterize the generic: Attribute<Collection<int, LineItem>, never>, or array{...} for a fixed shapeThe 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 elseA class-level @property or @property-read tag, such as @property array<string, mixed>|null $settingsRecord<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 modelThe 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 methodThe union, with every member imported. See Typing morphTo Relations
A morphTo() whose parents point at a subclass, or reach it through a ->using() pivotNothing. The union includes both automaticallyThe 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{...} shapeThe 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 typesNothing. This is automatic when the relation resolves to a single model and every filtered key is a database columnPick<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 getterNothing. The getter body is read when the signature and the docblock are both vagueThe shape the getter returns. See Accessor Getter Bodies
A query-selected attribute (selectRaw('… as rank')) has no column, cast or accessor behind itA class-level @property or @property-read tag naming itThe 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 unknownNothing. A leading ? is read as | nullThe 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:

LocationSourceJSDoc Placement
Model classDoc block above the classAbove the export interface declaration
ColumnsDoc block above the column's accessor methodAbove the column property
MutatorsDoc block above the mutator's accessor methodAbove the mutator property
RelationsDoc block above the relation methodAbove 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:

php
/** 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: User uses the enum's {Enum}Type union, which is how Laravel serializes the enum when it converts a model to JSON.
  • A parallel Resource interface: {Model}Resource types the same property as AsEnum<typeof Enum>, and the split template adds {Model}MutatorsResource for accessors and {Model}AllResource. AsEnum is the shape you get once the raw value is a full enum instance, either from Status.from(user.status) or from an API resource that serialized the enum with EnumResource.

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:

typescript
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:

php
// config/ts-publish.php
'timestamps_as_date' => true,

The setting changes every date column:

Config ValueGenerated Type
false (default)created_at: string
truecreated_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:

php
// 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 ​

CastTypeScript Type
array, collection, iterableunknown[]
AsCollection, AsEncryptedCollection, AsEnumCollection (bare)unknown[]
AsArrayObject, AsEncryptedArrayObject, AsEnumArrayObjectunknown[] | Record<string, unknown>
json, jsonb, objectobject
Illuminate\Database\Eloquent\CollectionRecord<string, unknown>
Illuminate\Support\Collectionunknown[] | Record<string, unknown>
Illuminate\Database\Eloquent\Casts\AsFluentobject

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 typeEmittedContainers
int, or omittedX[]list<X>, array<…>, iterable<…>, Collection<…>
stringRecord<string, X>array<…>, iterable<…>, Collection<…>
array-key, mixedX[] | 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 ​

CastTypeScript Type
AsStringable, AsUri, AsBinarystring
nullnull
mixedunknown
never (PHPStan)never
void (PHPStan)void
true / false (PHPStan)true / false
array-key (PHPStan)string | number
scalar (PHPStan)string | number | boolean
geometry, geographyunknown
vectornumber[]

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:

php
// 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:

php
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.

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 in included when 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 ValueRelation (hasMany(Post::class))CountExists
'snake'owned_teams: Team[]owned_teams_countowned_teams_exists
'camel'ownedTeams: Team[]ownedTeams_countownedTeams_exists
'pascal'OwnedTeams: Team[]OwnedTeams_countOwnedTeams_exists

Configuration Reference ​

The Configuration Reference lists every models.* config key, including the pipeline class overrides for advanced customization.

Released under the MIT License.