The honest part first: two runtimes, two paths
Eloquent lives in PHP. kastor lives in JavaScript runtimes: Node.js, Bun, Deno, Cloudflare Workers and other edge platforms. There is no PHP extension and no bridge, and this guide will not pretend otherwise. If your whole product is a Laravel monolith and you are happy with it, Eloquent is the right tool there.
Developers coming from Laravel Eloquent usually reach for kastor in one of two situations, and the guide covers both:
- Path A: moving to TypeScript. A team rewrites a Laravel app or splits a service out into Node.js or Bun, and wants an ORM that feels familiar: models, relations, eager loading, scopes, migrations, seeders and factories.
- Path B: TypeScript next to PHP. Laravel keeps running, and a new TypeScript API (a mobile backend, a real time service, a Next.js frontend with server code) reads and writes the same database.
Both start the same way: kastor reads the database Laravel built.
Step 1: pull the schema Laravel migrations built
In a new TypeScript project, install kastor and the driver for your database. Most Laravel apps run MySQL or MariaDB, so that is the example here; PostgreSQL works the same with pg.
npm install kastor-orm mysql2
npx kastor-orm init --db mysql
npx kastor db pull --out src/db/schema.tsBefore the pull, set DATABASE_URL in the .env that init wrote to the Laravel database, for example mysql://forge:secret@127.0.0.1:3306/laravel. db pull then introspects tables, columns, defaults, indexes and foreign keys created by php artisan migrate, and writes table() definitions with relations guessed from the keys. Laravel uses snake case columns, so the pulled schema keeps database names with .map() while your TypeScript reads camelCase. Here is a Laravel migration and model next to the kastor schema:
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title', 200);
$table->boolean('published')->default(false);
$table->json('meta')->nullable();
$table->timestamps();
$table->softDeletes();
});class Post extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['title', 'published', 'meta'];
protected $casts = ['published' => 'boolean', 'meta' => 'array'];
public function author() { return $this->belongsTo(User::class, 'user_id'); }
public function tags() { return $this->belongsToMany(Tag::class); }
public function comments() { return $this->hasMany(Comment::class); }
public function scopePublished($query) { return $query->where('published', true); }
}import { table, t, one, many } from 'kastor-orm'
export const users = table('users', {
id: t.bigId(),
name: t.string(),
email: t.string().unique(),
password: t.string(),
createdAt: t.datetime().map('created_at').nullable().defaultNow(),
updatedAt: t.datetime().map('updated_at').nullable().updatedAt(),
})
export const posts = table('posts', {
id: t.bigId(),
userId: t.bigint().map('user_id').references(() => users.id, { onDelete: 'cascade' }),
title: t.string(200),
published: t.boolean().default(false),
meta: t.json<{ tags?: string[]; cover?: string }>().nullable(),
createdAt: t.datetime().map('created_at').nullable().defaultNow(),
updatedAt: t.datetime().map('updated_at').nullable().updatedAt(),
deletedAt: t.datetime().map('deleted_at').nullable(),
}, { softDelete: 'deletedAt' })
export const tags = table('tags', {
id: t.bigId(),
name: t.string().unique(),
})
export const postTag = table('post_tag', {
postId: t.bigint().map('post_id').references(() => posts.id, { onDelete: 'cascade' }),
tagId: t.bigint().map('tag_id').references(() => tags.id, { onDelete: 'cascade' }),
}, { primaryKey: (c) => [c.postId, c.tagId] })
export const comments = table('comments', {
id: t.bigId(),
postId: t.bigint().map('post_id').references(() => posts.id, { onDelete: 'cascade' }),
body: t.text(),
createdAt: t.datetime().map('created_at').nullable().defaultNow(),
})
export const schema = { users, posts, tags, postTag, comments }
export const relations = ({ users, posts, tags, postTag, comments }: typeof schema) => ({
users: { posts: many(posts) },
posts: {
author: one(users, { fields: ['userId'], references: ['id'] }),
tags: many(tags, { through: postTag }),
comments: many(comments),
},
comments: { post: one(posts, { fields: ['postId'], references: ['id'] }) },
})Laravel's $table->id() is an unsigned big integer, so ids are t.bigId() and come back as JavaScript bigint. That is exact for every value MySQL can hold. If you prefer numbers in JSON responses, convert at the edge with Number(id) for ids you know stay small.
Step 2: decide who owns migrations
On path A, kastor takes over. Record the current database as the first kastor migration, then make every change in src/db/schema.ts:
npx kastor migrate baseline --name init
npx kastor migrate dev --name add_slug_to_posts
npx kastor migrate deployOn path B, keep Laravel as the owner while PHP is the main application. Run php artisan migrate as before, then npx kastor db pull to refresh the TypeScript schema and commit it with the change. Never run kastor migrate dev against that database while Laravel owns it, or the two histories drift apart. Later, when the TypeScript side becomes primary, baseline and switch owners in one step.
Note
Laravel keeps its history in the migrations table and kastor in _kastor_migrations. They never touch each other's table, so switching owners later is safe.
Step 3: Eloquent to kastor, concept by concept
| Eloquent and Laravel | kastor |
|---|---|
class Post extends Model | table('posts', ...), rows typed by InferSelect |
$fillable, $guarded | the create and update types, checked at compile time |
$casts boolean, array, datetime | t.boolean(), t.json<T>(), t.datetime() |
belongsTo, hasOne | one() |
hasMany | many() |
belongsToMany with a pivot | many(tags, { through: postTag }) |
scopePublished | a typed where object you spread |
with(['author']), withCount | include, _count |
whereHas, whereDoesntHave | some, none relation filters |
SoftDeletes, withTrashed, restore | softDelete option, withDeleted: true, restore |
paginate(15), cursorPaginate | take and skip with count, findPage |
firstOrCreate, updateOrCreate | upsert |
increment('views') | { views: { increment: 1 } } |
DB::transaction(fn) | db.$transaction(fn) |
DB::select($sql, $bindings) | db.$sql`...` |
php artisan migrate, migrate:status | npx kastor migrate deploy, migrate status |
migrate:fresh --seed, db:seed | npx kastor migrate reset, db seed |
php artisan tinker for data | npx kastor studio |
Eager loading is where kastor feels closest to Eloquent, with one improvement: with() runs one extra query per relation, while kastor loads the whole include tree in one statement per call.
$posts = Post::published()
->whereHas('tags', fn ($q) => $q->where('name', 'laravel'))
->with(['author:id,name', 'tags'])
->withCount('comments')
->latest()
->take(20)
->get();import { db } from '../db'
// Was: scopePublished
const published = { published: true } as const
export const latestTagged = (tag: string) =>
db.posts.findMany({
where: { ...published, tags: { some: { name: tag } } },
include: {
author: { select: { id: true, name: true } },
tags: true,
_count: { select: { comments: true } },
},
orderBy: { createdAt: 'desc' },
take: 20,
})Model events and observers have no counterpart. Put that logic in the function that writes, where TypeScript checks it. Accessors such as getFullNameAttribute become plain functions over the row type.
Step 4: seeders and factories
The seed entry of kastor.config.ts points at a file like DatabaseSeeder, and factory() covers model factories, including nested records like has():
import { defineSeed, factory } from 'kastor-orm'
import type { db as client } from './index'
export default defineSeed<typeof client>(async (db) => {
const users = factory(db.users, ({ seq }) => ({
name: `User ${seq}`,
email: `user${seq}@example.com`,
password: 'not-a-real-hash',
}))
// Was: User::factory()->count(10)->has(Post::factory()->count(3))->create()
await users.createMany(10, (i) => ({
posts: { create: [1, 2, 3].map((n) => ({ title: `Post ${n} of user ${i + 1}` })) },
}))
})npx kastor db seed
npx kastor migrate resetPath B in practice: sharing the database with Laravel
When PHP and TypeScript write the same tables, the TypeScript side has to follow the conventions Eloquent applies for you:
- Timestamps. Eloquent fills
created_atandupdated_at. In kastor,defaultNow()fills the first on create and.updatedAt()fills the second on every create and update, so TypeScript writes keep the same convention. - Soft deletes. Declare
softDelete: 'deletedAt'on tables that use the trait, so TypeScript reads skip trashed rows exactly like Eloquent does. - Casts. A column cast to
arrayholds JSON text. Declare it ast.json<Shape>()and write the same shape PHP expects. - Auth. Laravel sessions are encrypted with
APP_KEYand are hard to share. Tokens are easier: Sanctum stores a SHA-256 hash of each personal access token, so the TypeScript API can verify the tokens Laravel issues. - Connections. PHP-FPM workers and the Node pool both hold connections. Keep their sum under the server's
max_connections.
import { createHash } from 'node:crypto'
import { db } from '../db'
// Verifies a "Bearer 12|plaintext" token issued by Laravel Sanctum.
export async function userFromToken(header: string | undefined) {
const [id, plain] = (header ?? '').replace(/^Bearer\s+/i, '').split('|')
if (!id || !plain) return null
const hash = createHash('sha256').update(plain).digest('hex')
const rows = await db.$sql<{ tokenable_id: string }>`
select tokenable_id from personal_access_tokens
where id = ${Number(id)} and token = ${hash} and tokenable_type = ${'App\\Models\\User'}
`
return rows[0] ? db.users.findUnique({ where: { id: BigInt(rows[0].tokenable_id) } }) : null
}Pitfalls to check
- Morph relations.
morphToandmorphManystore a class name and an id without a foreign key. kastor relations need real keys, so query those with a filter on the type column, or with$sql. - Tinyint flags. On MySQL, Laravel booleans are
tinyint(1). Check that the pulled schema typed them ast.boolean()and fix any that came back as integers. - Password hashes. Laravel writes bcrypt hashes with the
$2y$prefix. Check that the Node bcrypt library you pick accepts it before porting logins. - Enum columns. PHP enums are often stored as strings. Use
t.enum([...])with the same values so both sides agree. - Time zones. Laravel stores timestamps in the app time zone, usually UTC. Make the Node process and the driver use the same zone.
Once the TypeScript service is running, the Express, Fastify and AdonisJS guides show where the client lives in each framework. AdonisJS in particular will feel familiar to Laravel developers.
Questions people ask
Q1Can I use kastor inside a Laravel or PHP project?
No. kastor is a TypeScript library and runs in JavaScript runtimes: Node.js, Bun, Deno and edge platforms such as Cloudflare Workers. A PHP application keeps using Eloquent. What you can do is run a TypeScript service next to Laravel on the same database, or move a service to TypeScript.
Q2Can Laravel and a Node.js API share one database?
Yes. Run npx kastor db pull against the Laravel database to get a typed schema, pick one side to own migrations, and make the TypeScript side respect Laravel conventions: created_at and updated_at, deleted_at for soft deletes and the same JSON shapes for cast columns.
Q3What is the kastor equivalent of Eloquent with()?
include. Post::with(['author', 'tags'])->withCount('comments') becomes db.posts.findMany({ include: { author: true, tags: true, _count: { select: { comments: true } } } }), loaded in one statement per call.
Q4Are there seeders and factories?
Yes. A seed file exports defineSeed(async (db) => ...) and runs with npx kastor db seed. factory(db.users, ({ seq }) => data) builds and creates test rows, and npx kastor migrate reset plays the role of migrate:fresh --seed.
Q5Will kastor read my Laravel migrations folder?
No. It reads the database those migrations built. db pull produces the TypeScript schema, and migrate baseline records the current state if you want kastor to own future changes. Laravel's migrations table is left alone.