Stop writing TypeScript types by hand. Generate them from your Laravel models.
Reads your $casts, PHP enums, migrations (for nullability), and relationships —
and produces accurate .ts files your Vue, React, or Svelte frontend can import immediately.
Installation · Configuration · Usage · Type Mapping · Casting Logic
You add a column to your users table. You update the model. You run the migration.
Then you open your frontend and realise:
score is number | null or just numberrole field is typed as string but it should be 'admin' | 'editor' | 'viewer'deleted_at field and now there's a runtime error in productionYou shouldn't have to maintain types in two places.
One command. Run it after any migration or model change.
php artisan typegen:generate
✓ user.ts
✓ post.ts
✓ blog-post.ts
✓ model-helpers.ts
✓ index.ts
✅ Done! Generated 5 type file(s) → resources/js/types/models
Your Laravel model:
// app/Models/User.php
class User extends Model
{
use SoftDeletes;
protected $fillable = ['name', 'email', 'active'];
protected $hidden = ['password', 'remember_token'];
protected $casts = [
'active' => 'boolean',
'role' => UserRole::class,
'score' => 'decimal:2',
'meta' => 'array',
];
}
enum UserRole: string
{
case Admin = 'admin';
case Editor = 'editor';
case Viewer = 'viewer';
}
Your migration:
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email');
$table->boolean('active')->default(true);
$table->string('role');
$table->decimal('score', 8, 2)->nullable(); // ← detected automatically
$table->json('meta')->nullable();
$table->timestamps();
$table->softDeletes();
});
Generated resources/js/types/models/user.ts:
// Auto-generated by eloquent-typegen.
// Run `php artisan typegen:generate` to refresh.
import type { Nullable } from './model-helpers';
export type UserRole = 'admin' | 'editor' | 'viewer';
export interface User {
readonly id: number;
name: string;
email: string;
active: boolean;
role: UserRole;
score?: Nullable<number>;
meta?: Nullable<Record<string, unknown>>;
created_at?: Nullable<string>;
updated_at?: Nullable<string>;
deleted_at?: Nullable<string>;
}
/** Fields required to create a new User */
export type CreateUserPayload = Omit<User, 'id' | 'created_at' | 'updated_at' | 'deleted_at'>;
/** Fields allowed when updating a User */
export type UpdateUserPayload = Partial<CreateUserPayload>;
Notice what happened automatically:
password and remember_token are absent — they're in $hiddenscore and meta are optional and nullable — read from your migration filerole is 'admin' | 'editor' | 'viewer', not just string — derived from your PHP enumdeleted_at is included because you use SoftDeletesCreateUserPayload and UpdateUserPayload are generated for freeRequirements: PHP 8.1+, Laravel 10 / 11 / 12 / 13
Install as a dev dependency — this package has zero production footprint:
composer require vincentndegwa/eloquent-typegen --dev
Laravel's auto-discovery registers the package automatically. No manual config needed.
Optionally publish the config file:
php artisan vendor:publish --tag=typegen-config
php artisan typegen:generate
php artisan typegen:generate --model=User --model=Post
php artisan typegen:generate --dry-run
php artisan typegen:generate --path=src/types/api
php artisan typegen:generate --no-relations
<script setup lang="ts">
import type { User, UpdateUserPayload } from '@/types/models'
const props = defineProps<{ user: User }>()
async function save(payload: UpdateUserPayload) {
await $fetch(`/api/users/${props.user.id}`, {
method: 'PATCH',
body: payload,
})
}
</script>
<template>
<!-- TypeScript knows role is 'admin' | 'editor' | 'viewer' — nothing else compiles -->
<AdminBadge v-if="user.role === 'admin'" />
<!-- TypeScript knows score can be null -->
<span>{{ user.score ?? 'No score yet' }}</span>
</template>
import type { User, CreateUserPayload, Paginated } from '@/types/models'
async function getUsers(): Promise<Paginated<User>> {
const res = await fetch('/api/users')
return res.json()
}
function UserCard({ user }: { user: User }) {
return (
<div>
<h2>{user.name}</h2>
{/* 'superadmin' would be a compile error */}
{user.role === 'admin' && <AdminBadge />}
{/* TypeScript knows score is number | null */}
<p>Score: {user.score?.toFixed(2) ?? 'N/A'}</p>
</div>
)
}
function CreateUserForm() {
const [form, setForm] = useState<CreateUserPayload>({
name: '',
email: '',
active: true,
role: 'viewer',
})
// ...
}
Svelte fully supports TypeScript — add lang="ts" to your <script> block:
<script lang="ts">
import type { User } from '$lib/types/models'
let { user }: { user: User } = $props()
</script>
{#if user.role === 'admin'}
<AdminBadge />
{/if}
<p>Score: {user.score ?? 'No score yet'}</p>
// Inertia passes your model data as page props — type them directly:
import type { User, Paginated } from '@/types/models'
defineProps<{
users: Paginated<User>
auth: { user: User }
}>()
config/typegen.php after publishing:
return [
/*
|--------------------------------------------------------------------------
| Model Paths
|--------------------------------------------------------------------------
| Directories to scan for Eloquent models. Relative to app_path().
*/
'model_paths' => ['Models'],
/*
|--------------------------------------------------------------------------
| Output Directory
|--------------------------------------------------------------------------
| Where to write .ts files. Relative to base_path() or absolute.
| The default works for Vite-based projects (Vue, React, Svelte, Inertia).
*/
'output_path' => 'resources/js/types/models',
/*
|--------------------------------------------------------------------------
| Index File
|--------------------------------------------------------------------------
| Generates an index.ts barrel — one import for all your model types.
*/
'generate_index' => true,
/*
|--------------------------------------------------------------------------
| Helpers File
|--------------------------------------------------------------------------
| Generates model-helpers.ts with Nullable<T>, Paginated<T>, ApiError, etc.
*/
'generate_helpers' => true,
/*
|--------------------------------------------------------------------------
| Date Type
|--------------------------------------------------------------------------
| How date/datetime columns are typed. 'string' is the safe default because
| Laravel serialises dates as ISO strings over the wire.
*/
'date_type' => 'string', // 'string' | 'Date'
/*
|--------------------------------------------------------------------------
| Excluded Models
|--------------------------------------------------------------------------
| Models to skip. Accepts FQCNs or short class names.
*/
'excluded_models' => [
// 'App\Models\PersonalAccessToken',
],
/*
|--------------------------------------------------------------------------
| Custom Type Map
|--------------------------------------------------------------------------
| Override the TypeScript type for specific cast classes.
*/
'custom_type_map' => [
// 'App\Casts\Money' => '{ amount: number; currency: string }',
],
/*
|--------------------------------------------------------------------------
| Relationships
|--------------------------------------------------------------------------
| Include relationship methods as optional properties on generated types.
*/
'include_relationships' => true,
/*
|--------------------------------------------------------------------------
| Vendor Models
|--------------------------------------------------------------------------
| Include vendor models referenced by relations (e.g. notifications).
*/
'include_vendor_models' => true,
/*
|--------------------------------------------------------------------------
| Additional Models
|--------------------------------------------------------------------------
| Explicit model classes to always include in generation.
*/
'additional_models' => [
// 'Illuminate\Notifications\DatabaseNotification',
],
/*
|--------------------------------------------------------------------------
| Read Migrations
|--------------------------------------------------------------------------
| Parse migration files to detect nullable columns accurately.
| No database connection is required.
*/
'read_migrations' => true,
];
| PHP / Laravel cast | TypeScript type |
|---|---|
int, integer, bigInteger |
number |
float, double, decimal, decimal:2 |
number |
bool, boolean |
boolean |
string, char, text, uuid, ulid |
string |
date, datetime, timestamp |
string (configurable to Date) |
immutable_date, immutable_datetime |
string (configurable to Date) |
array, json, object |
Record<string, unknown> |
collection |
unknown[] |
BackedEnum (string-backed) |
Union of string literals |
BackedEnum (int-backed) |
Union of number literals |
UnitEnum |
Union of case name strings |
AsCollection, AsArrayObject |
unknown[] |
AsStringable |
string |
AsEnumCollection:MyEnum |
MyEnum[] |
Custom cast (no toTypeScript()) |
unknown |
The generator reads these sources in this order:
$casts for field type mapping$fillable and $dates to discover fieldsnullable() columns$hidden to exclude fieldsCustom casts default to unknown. You can override them in two ways:
custom_type_map:// config/typegen.php
'custom_type_map' => [
'App\Casts\Money' => '{ amount: number; currency: string }',
],
toTypeScript() static method on the cast:class MoneyCast
{
public static function toTypeScript(): string
{
return '{ amount: number; currency: string }';
}
}
Add a static toTypeScript() method to any custom cast class and the generator uses it automatically:
class MoneyCast implements CastsAttributes
{
public static function toTypeScript(): string
{
return '{ amount: number; currency: string }';
}
// get() and set() ...
}
Or use the config map for third-party casts you can't modify:
'custom_type_map' => [
'Brick\Money\Money' => '{ amount: number; currency: string }',
],
model-helpers.ts ships automatically with every project:
/** Marks a field as possibly null — mirrors Laravel's nullable() */
export type Nullable<T> = T | null
/** A model primary key */
export type ModelId = number
/** Matches Laravel's LengthAwarePaginator JSON output */
export interface Paginated<T> {
data: T[]
current_page: number
last_page: number
per_page: number
total: number
from: number | null
to: number | null
first_page_url: string
last_page_url: string
next_page_url: string | null
prev_page_url: string | null
path: string
links: { url: string | null; label: string; active: boolean }[]
}
/** Standard Laravel validation error response */
export interface ApiError {
message: string
errors?: Record<string, string[]>
}
{
"scripts": {
"typegen": "php artisan typegen:generate",
"dev": "npm run typegen && vite",
"build": "npm run typegen && vite build"
}
}
# .husky/pre-commit
php artisan typegen:generate
git add resources/js/types/models/
- name: Generate TypeScript types
run: php artisan typegen:generate
- name: Fail if types are out of sync
run: git diff --exit-code resources/js/types/models/
This fails the pipeline if a developer changed a model and forgot to regenerate types, keeping your team honest without any manual process.
| Version | Feature |
|---|---|
| v1.0 | Model + migration scanning — everything documented here |
| v1.1 | Zod schema output — generates z.object({...}) alongside .ts types |
| v1.2 | --watch mode — re-generates on model or migration file changes |
| v2.0 | Laravel API Resource scanning — reads toArray() in JsonResource classes to generate types that match exactly what your API returns, not just what the model holds |
| v2.1 | Spatie Laravel Data support |
| v3.0 | Route + controller tracing — per-route types like Wayfinder |
v1 is built for projects returning models directly or using simple arrays. v2 is for teams using full API Resource transformations — it's the correct source of truth for API-first Laravel.
Does this require a database connection? No. It reads your model classes and migration files from disk — no DB connection needed. Safe to run in CI without any environment setup.
Does it work with Inertia.js? Yes. Inertia passes Laravel model data as page props. Typed props are exactly what this package produces.
What about models that use $guarded = [] instead of $fillable?
The generator falls back to migration-detected columns. If you have neither $fillable nor migrations, it generates id and timestamps only. Running with migrations enabled is recommended for these cases.
Can I exclude specific fields from output?
Add them to $hidden on the model. Hidden fields are never included in generated types.
What if I use API Resources and only expose some fields?
v1 includes all non-hidden model fields. v2 (on the roadmap) fixes this by reading your JsonResource::toArray() directly.
Does Svelte support TypeScript?
Yes, fully. Add lang="ts" to your <script> tag. SvelteKit projects ship with TypeScript configured out of the box.
Contributions are welcome. To get started locally:
git clone https://github.com/VincentNdegwa/eloquent-typegen.git
cd eloquent-typegen
composer install
composer test # run the test suite
composer analyse # PHPStan static analysis
composer format # Laravel Pint code style
Please write tests for any new behaviour. Open an issue before starting large changes so we can align on approach.
MIT — see LICENSE for details.
Built by developers who were tired of TypeScript lying about their Laravel data.
If this saves you time, give it a ⭐ — it helps others find it.