vincentndegwa / eloquent-typegen
Generate TypeScript types from Eloquent models
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/filesystem: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.0
- nikic/php-parser: ^5.7
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0|^12.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan: ^1.0
- phpunit/phpunit: ^10.0|^11.0|^12.0
README
⚡ eloquent-typegen
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
The Problem
You add a column to your users table. You update the model. You run the migration.
Then you open your frontend and realise:
- Your TypeScript type still has the old fields
- You're not sure if
scoreisnumber | nullor justnumber - Your
rolefield is typed asstringbut it should be'admin' | 'editor' | 'viewer' - Someone added a
deleted_atfield and now there's a runtime error in production
You shouldn't have to maintain types in two places.
The Solution
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
What It Generates
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:
passwordandremember_tokenare absent — they're in$hiddenscoreandmetaare optional and nullable — read from your migration fileroleis'admin' | 'editor' | 'viewer', not juststring— derived from your PHP enumdeleted_atis included because you useSoftDeletesCreateUserPayloadandUpdateUserPayloadare generated for free
Installation
Requirements: 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
Usage
Generate all types
php artisan typegen:generate
Generate for specific models only
php artisan typegen:generate --model=User --model=Post
Preview without writing files
php artisan typegen:generate --dry-run
Custom output path
php artisan typegen:generate --path=src/types/api
Skip relationships
php artisan typegen:generate --no-relations
Using the Generated Types
Vue 3
<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>
React
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 5
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.js
// 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 } }>()
Configuration
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, ];
Type Mapping
| 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 |
Casting Logic
The generator reads these sources in this order:
$castsfor field type mapping$fillableand$datesto discover fields- Migrations for
nullable()columns $hiddento exclude fields- Relationships (optional) to include related types
Custom Casts
Custom casts default to unknown. You can override them in two ways:
- Config map in
custom_type_map:
// config/typegen.php 'custom_type_map' => [ 'App\Casts\Money' => '{ amount: number; currency: string }', ],
- Cast class method by defining a
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 }', ],
Generated Helpers
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[]> }
Automating Generation
Before every Vite build
{
"scripts": {
"typegen": "php artisan typegen:generate",
"dev": "npm run typegen && vite",
"build": "npm run typegen && vite build"
}
}
Pre-commit hook (Husky)
# .husky/pre-commit
php artisan typegen:generate
git add resources/js/types/models/
CI — fail if types are stale
- 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.
Roadmap
| 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.
FAQ
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.
Contributing
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.
License
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.