Models
The Laravel TypeScript Publisher converts Eloquent models into TypeScript interfaces for their columns, mutators, and relations — resolved via a reflection + database-schema waterfall so the generated types stay accurate without you hand-maintaining them.
As mentioned in Installation & Usage, models don't need the @tolki/ts runtime package at all (unlike enums and routes) — the output is plain TypeScript interfaces, with one exception: enum-typed columns optionally use the AsEnum<> type from @tolki/types (see Enum-Typed Columns).
How Models Are Generated
- One
.tsfile is generated per model, at a modular, namespace-derived path (e.g.App\Models\User→app/models/user.ts). - Barrel
index.tsfiles re-export everything (export * from './user') per namespace directory, the same as enums. - Each column's type is resolved through a waterfall: an explicit
#[TsCasts]override first, then the model's cast (casts()method or$castsproperty, including a#[TsType]on a custom cast class), then the raw database column type — see the Type Mapping Reference for the full default table. - Mutators (new-style
Attributeaccessors and old-stylegetXAttribute()methods) and relations are inspected the same way, and split into their own interfaces by default — see Model Templates.
Anatomy of a Generated Model
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 {}Userholds the raw columns — enum columns (role,membership_level) are typed with the plain{Enum}Typeunion, matching how Eloquent serializes aBackedEnumto JSON.UserResourceis the enum-resolved variant — see Enum-Typed Columns.settingsshows an inline object type — the result of a#[TsCasts]override.UserMutatorsholds accessor-based properties (new-styleAttributeor old-stylegetXAttribute()), each with its PHPDoc description carried over as a JSDoc comment — see PHPDoc Descriptions.UserRelationsincludes every relation plus a generated_countand_existsproperty per relation (mirroring Laravel'swithCount/withExists) — including polymorphic relations and framework-provided ones (notifications, imported from a generatedilluminate/notificationsnamespace).UserAll/UserAllResourceare convenience interfaces combining all three — only generated when there's more than one non-empty interface to combine.
Model Templates
By default, a model is split into up to four interfaces ({Model}, {Model}Mutators, {Model}Relations, {Model}All) so a given page only needs to import what it actually uses. Switch to a single combined interface with the model-full template:
| Template | Description |
|---|---|
laravel-ts-publish::model-split | (Default) Separate interfaces for properties, mutators, and relations, plus an All interface combining them. |
laravel-ts-publish::model-full | Combines properties, mutators, and relations into one interface (grouped with // Columns / // Mutators / // Relations comments). |
// config/ts-publish.php
'models' => [
'template' => 'laravel-ts-publish::model-full',
],TIP
Any mutator listed in the model's $appends array is always included in the properties interface (even in the split template), since Laravel always includes appended attributes when serializing a model to JSON.
Publish the views (php artisan vendor:publish --tag="laravel-ts-publish-views") if you want to customize either template's structure, then point models.template at your published/custom view.
Choosing between interfaces in a form
The split template lets you compose only the pieces a page needs. For example, an Inertia form that needs the full User shape plus just one relation flag:
import { useForm } from "@inertiajs/vue3";
import type { User, UserRelations } from "@js/types/data/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, the equivalent requires Omit-ing every relation property you don't need instead of only picking what you want:
import type { User } from "@js/types/data/models";
interface UserForm extends Omit<
User,
| "admin"
| "profile"
| "posts"
| "profile_count"
| "posts_count"
| "posts_exists"
> {
profile: User["profile"] | null;
}Additionally, when using the model-full template, since all relations are included, it creates deeply dependent types where errors in a relation 2, 3 or more levels deep can propagate and cause TypeScript to raise errors. It can be good way to make sure all your models have proper TypeScript types for forms and other contexts where you only need a subset of the model's relations, but it can be a bit annoying to track down and fix.
Nullable Relations
Singular relations are automatically typed with | null based on the relation type and, where relevant, whether the underlying foreign key column is nullable:
| Relation Type | Strategy | Behavior |
|---|---|---|
HasOne | nullable | Always add null — the related record may not exist. |
MorphOne | nullable | Always add null. |
HasOneThrough | nullable | Always add null. |
BelongsTo | fk | Add null only when the foreign key column is nullable in the database. |
MorphTo | morph | Add null when either the morph type or morph id column is nullable. |
HasMany, BelongsToMany, MorphMany, MorphToMany | never | Never nullable (returns an empty array, not null). |
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
// config/ts-publish.php
'models' => [
'nullable_relations' => false, // keep all singular relations non-nullable
],Override the strategy per relation type (including custom third-party relation classes) via models.relation_nullability_map — keys are FQCNs, values are 'nullable', 'never', 'fk', or 'morph':
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
],
],See AbeTwoThree\LaravelTsPublish\RelationMap for the full default map.
Model Attributes
All attributes live under the AbeTwoThree\LaravelTsPublish\Attributes namespace.
| Attribute | Target | Description |
|---|---|---|
#[TsCasts] | casts() method, $casts property, or model class | Override/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 an entire model, or a specific accessor/relation. |
#[TsCasts]
Takes an array mapping property names to either a raw TypeScript type string, or ['type' => ..., 'import' => ...] for a type that needs importing from your own files:
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',
];
}
}import { ProductDimensions } from "@js/types/product";
export interface User {
metadata: { label: string; value: string }[];
settings: Record<string, unknown>;
dimensions: ProductDimensions;
}#[TsCasts] can be placed on the casts() method, the $casts property, or the model class itself — all three accept the same array shape.
TIP
Prefer placing #[TsCasts] on casts() / $casts so the TypeScript override sits next to the actual PHP cast. Since it can also override mutator and relation types, place it on the class itself when you need to override those instead.
However, when extending models from the vendor directory, it can be useful to place #[TsCasts] on the class itself to override types for relations or mutators without modifying the original vendor cast definitions.
#[TsType]
For a custom cast class used across multiple models/properties, put #[TsType] on the cast class once instead of repeating #[TsCasts] everywhere it's used:
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];
}
}import { ProductDimensions } from "@js/types/product";
export interface Product {
dimensions: ProductDimensions;
}#[TsType] also accepts a plain string (#[TsType('{width: number, height: number}')]) when the type doesn't need an import.
PHPDoc Descriptions
Doc blocks are read automatically and converted to 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 new-style accessor (protected function name(): Attribute) is checked before the old-style one (public function getNameAttribute()). @-prefixed lines (@param, @return, @phpstan-type, ...) are stripped — only the prose description carries over.
/** 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');
}
}Timestamps as Date Objects
Timestamp columns (date, datetime, timestamp, and their immutable variants) map to string by default:
// config/ts-publish.php
'timestamps_as_date' => true,| Config Value | Generated Type |
|---|---|
false (default) | created_at: string |
true | created_at: Date |
Custom TypeScript Type Mappings
The default PHP-to-TypeScript mapping is intentionally broad. Override or extend it with custom_ts_mappings (keys are matched case-insensitively):
// config/ts-publish.php
'custom_ts_mappings' => [
'binary' => 'Blob',
'json' => 'Record<string, unknown>', // overrides the default 'object' mapping
'money' => 'number', // adds a new mapping
],TIP
Custom mappings are merged with the built-in map and take precedence. For a per-property override instead of a global one, use #[TsCasts] or #[TsType] instead.
Type Mapping Reference
Numbers
bigint, decimal, double, float, integer, int, numeric, number, mediumint, smallint, year, real → number
Booleans
bool, boolean, tinyint → boolean
Strings
char, character, enum, longtext, mediumtext, string, text, varchar, encrypted, uuid, guid, hashed, time, timetz, timestamptz, numeric-string → string
Arrays & Objects
| Cast | TypeScript Type |
|---|---|
array, collection | unknown[] |
AsArrayObject, AsCollection, AsEncryptedArrayObject, AsEncryptedCollection, AsEnumArrayObject, AsEnumCollection | 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 |
Dates & Times
date, immutable_date, datetime, immutable_datetime, immutable_custom_datetime, timestamp, and Carbon/CarbonImmutable casts all resolve through timestamps_as_date → string (default) or Date.
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 |
Enum-Typed Columns ({Model}Resource)
A column, mutator, or relation typed to a BackedEnum or UnitEnum gets two representations:
- The base interface (
User) types it as the plain{Enum}Typeunion — matching how Laravel serializes aBackedEnumwhen a model is cast to JSON. - A parallel
{Model}Resource/{Model}MutatorsResource/{Model}AllResourceinterface types the same property withAsEnum<typeof Enum>instead — the shape you get once you've resolved the raw value to a full enum instance (e.g.Status.from(user.status), or a Laravel API Resource that already serialized the enum viaEnumResource).
import { Role } from "@js/types/data/enums";
import type { User, UserResource } from "@js/types/data/models";
function displayRole(user: User) {
const resolved: UserResource["role"] = user.role
? Role.from(user.role)
: null;
// resolved?.label, resolved?.value, etc. — full enum instance, not just the raw value
}WARNING
The {Model}Resource variants (and the AsEnum import) are only generated when enums.use_tolki_package is true (the default). Set it to false and enum columns are typed with {Enum}Type only, everywhere.
Filtering & Excluding Models
Same include/exclude pattern used by 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'],
],#[TsExclude] on the model class excludes the whole model; on an accessor or relation method, it excludes just 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 the full attribute behavior shared across models, enums, resources, and routes.
Casing
models.relationship_case ('snake' (default), 'camel', or 'pascal') controls the casing of relation names and their generated _count / _exists properties:
| Config Value | Relation (hasMany(Post::class)) | Count | Exists |
|---|---|---|---|
'snake' | posts: Post[] | posts_count | posts_exists |
'camel' | posts: Post[] | postsCount | postsExists |
'pascal' | Posts: Post[] | PostsCount | PostsExists |
Configuration Reference
The full list of models.* config keys — including pipeline class overrides for advanced customization — lives in the Configuration Reference.