Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Oi Laravel Ts Laravel Package

oi-lab/oi-laravel-ts

A Laravel package that automatically generates TypeScript interfaces from your Eloquent models, with relationship support, custom casts, DataObjects, PHPDoc types, watch mode, and namespace filters for customization.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and generate once

composer require oi-lab/oi-laravel-ts
php artisan vendor:publish --tag=oi-laravel-ts-config
php artisan oi:gen-ts

The command scans app/Models, introspects each Eloquent model (columns, casts, relationships, primary key type) and writes a single TypeScript module to config('oi-laravel-ts.output_path') — by default resources/js/types/interfaces.ts.

Where to look first

  1. config/oi-laravel-ts.php — almost every behaviour is toggled here; skim the whole file before writing code, because the defaults (discover_related_models: true, with_counts: true) already do more than you expect.
  2. ARCHITECTURE.md in the repo — the two-pipeline split (Eloquent analysis → TypeScript emission) tells you where a wrong output originates. Most bugs are either "the model was analysed wrong" (Pipeline 1) or "the type was rendered wrong" (Pipeline 2).
  3. The generated interfaces.ts itself — read it in full on the first run. It is the real specification of what you get.

First use case: typed Inertia props

The highest-leverage entry point is replacing hand-written prop interfaces:

// resources/js/Pages/Dashboard.vue
import { IUser } from '@/types/interfaces'

defineProps<{ user: IUser }>()

You get user.posts?: IPost[], user.posts_count?: number, and id: string for UUID models without writing any of it. Run php artisan oi:gen-ts after each migration batch and the compiler tells you what broke.

First sanity check

Make one model with a HasMany, a datetime cast, and a nullable column, generate, and confirm the output matches the README example (email_verified_at?: string, posts?: IPost[], posts_count?: number). If your nullable string column comes out as bio: string rather than bio?: string | null, you've hit the nullability quirk described below — decide early how you feel about it, because it affects every model.

Implementation Patterns

Generate in dev, verify in CI

Watch mode for the inner loop, a hard check in CI so drift never merges:

# local
php artisan oi:gen-ts --watch

# CI
php artisan oi:gen-ts
git diff --exit-code resources/js/types/interfaces.ts

This turns the generator into a contract test: if a migration changes a column and nobody re-ran the command, the build fails.

Treat the output as build artifact, not source

Pick one and be consistent: commit interfaces.ts (reviewable diffs, no build step dependency) or gitignore it and generate in the asset pipeline. Committing it is usually better for a small team — the generated diff doubles as a schema change review.

DTOs as the source of truth for API payloads

Eloquent interfaces describe your database, not what your API returns. When the two diverge (computed fields, camelCase, renamed relations), register your spatie/laravel-data namespace and flip the model over to the DTO:

'data_namespaces' => ['App\\Data'],
'data_replaces_model' => true,
'data_for_model' => [
    App\Models\Knowledge::class => App\Data\Knowledge\KnowledgeData::class,
],

The DTO pipeline gives you things the model pipeline can't: camelCase property names preserved verbatim, backed enums as literal unions ('draft' | 'published' | 'archived'), nested DTOs as I{Name}, and @var Foo[] annotations expanded to IFoo[]. Detection is structural, so you are not locked into a package version of spatie/laravel-data.

Model → DTO mapping via fromModel()

Prefer relying on the inferred mapping from static fromModel(Model $model): self over data_for_model — it keeps the relationship in one place and the config stays empty. Only enumerate explicitly when the factory lives elsewhere or is named differently.

Extension interfaces for package/tenant model variants

Instead of hand-maintaining a superset type, let the generator do the inheritance:

'extended_namespaces' => ['OiLab\\Prestashop\\Extended\\Models'],

emits export interface IUserExtended extends IUser { prestashop_id: number }. Pair this with excluded_namespaces for the base package models you don't want leaking into your schema at all.

Custom props for computed/accessor fields

Anything not in the schema ($appends attributes, accessor output) has to be declared:

'custom_props' => [
    'User' => [
        'full_name' => 'string',
        'avatar_url' => 'string | null',
    ],
],

Same mechanism imports external types with the path|TypeName syntax:

'settings' => '@/types/settings|UserSettings',

which produces a real import { UserSettings } from '@/types/settings' at the top of the file.

Inertia shared props as the integration seam

Generate interfaces, then build one PageProps type that composes them, so page components never import from the generated file directly:

// resources/js/types/index.d.ts
import { IUser } from './interfaces'
export interface SharedProps { auth: { user: IUser } }

When the generator's output shape changes, only this file needs updating rather than forty pages.

Gotchas and Tips

? vs | null is a real semantic split — don't ignore it

For DTOs, ? means "the key may be absent" (Optional / Lazy) and | null means "always present, value may be null". A default value makes nothing optional. This is the correct model of JSON, but it will surprise you if you expect ? to mean "nullable". The legacy behaviour is one config flag away:

'data_nullable_style' => 'optional', // legacy: nullable OR defaulted => `?`

Don't flip this mid-project; it will produce a large, meaningless diff and weaken the types.

data_replaces_model silently breaks inbound relations

The README flags it, and it's the sharpest edge in the package: if model A has a relation to replaced model B, IA will reference IB — an interface that is no longer emitted. The TypeScript compile fails in a place that has nothing to do with your change. Mitigation: only replace models that nothing else relates to, or add a custom_props stub for the missing interface.

Excluded namespaces strip relation fields, not just models

excluded_namespaces drops the models and removes any relationship field pointing at them from other interfaces. That's usually what you want for spatie/laravel-permission models, but it silently changes your types — a roles?: IRole[] property simply disappears. Diff the generated file when you touch this config.

Undocumented props_with_types

It appears in the published config but not in the README. Read the config file's comments and the TypeExtractor source before using it; expect it to be overridden by casts or schema inference depending on order. Test with one property before rolling it out.

Custom casts must resolve to a known type

A cast returning an untyped array or mixed yields any/unknown and quietly degrades every interface that embeds it. Make the cast's get() return a typed object with typed constructor-promoted properties — that's what DataObjectAnalyzer walks. Nullable DataObjects come out as IMetadata | null, which is correct; don't "fix" it.

Primary key detection needs the trait, not just the intent

id: string requires HasUuids / HasUlids or an explicit protected $keyType = 'string'. A model using a UUID column without either will still emit id: number and you will only find out at runtime.

save_schema is the debugging switch you want

When the emitted TypeScript looks wrong, you can't tell whether the model was analysed wrong or rendered wrong. Turn on:

'save_schema' => true,

and inspect the intermediate schema.json. Wrong there → Pipeline 1 (casts, relationships, discovery). Right there → Pipeline 2 (conversion/imports). This single flag saves the most time.

Watch mode is naive polling — scope it

--watch monitors the models directory. It won't see changes to config/oi-laravel-ts.php, to DTO namespaces outside that directory, or to migrations. Restart it after config edits, and re-run manually after php artisan migrate.

Models outside app/Models are invisible by default

Package models, domain modules, and anything under App\Domain\*\Models need included_model_namespaces. Related models discovered through traits (e.g. spatie/laravel-permission) are followed via discover_related_models, so you may get interfaces you never asked for — check the generated file for strays before assuming exclusion is needed.

DTO short-name collisions

Two DTOs named AddressData in different namespaces both want IAddressData. The second one silently wins or is skipped. Use data_aliases proactively in any codebase with Billing\ and Shipping\ namespaces.

CI must have a migrated database

The analysis pipeline reads schema and cast metadata; a fresh CI container without migrations will produce a schema with missing columns, and your git diff --exit-code gate will fail confusingly. Run php artisan migrate --force (or a schema dump) before oi:gen-ts.

Zero dependents, one star — pin it and own the fallback

With no dependents and a single maintainer, treat the package as a dependency you may need to fork. Practical hedges: pin an exact version in composer.json, always commit the generated output so removing the package is a one-commit revert, and keep custom_props usage minimal (it's the part most likely to need re-implementing by hand). The architecture is modular — ModelInterfaceGenerator, ImportManager, TypeScriptTypeConverter — so a forked or local path repository (documented in the README) is genuinely viable if you need a bespoke renderer.

Extension points to reach for, in order of cost

custom_props → props_with_types → included/excluded/extended_namespaces → data_for_model + data_replaces_model → a local path-repository fork overriding ModelInterfaceGenerator. Exhaust each level before climbing to the next; the config surface is broad enough that most "I need to patch the package" moments are actually a config option.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core