A parte honesta primeiro: dois runtimes, dois caminhos
O Eloquent vive no PHP. O kastor vive em runtimes JavaScript: Node.js, Bun, Deno, Cloudflare Workers e outras plataformas edge. Não existe extensão PHP nem ponte, e este guia não vai fingir que existe. Se o seu produto é um monólito Laravel e você está satisfeito com ele, o Eloquent é a ferramenta certa ali.
Quem vem do Laravel Eloquent costuma chegar ao kastor em uma de duas situações, e o guia cobre as duas:
- Caminho A: levar para TypeScript. O time reescreve uma aplicação Laravel ou separa um serviço em Node.js ou Bun, e quer um ORM com cara conhecida: models, relações, eager loading, scopes, migrações, seeders e factories.
- Caminho B: TypeScript ao lado do PHP. O Laravel continua rodando, e uma API TypeScript nova (um backend mobile, um serviço de tempo real, um frontend Next.js com código no servidor) lê e grava no mesmo banco.
Os dois começam do mesmo jeito: o kastor lê o banco que o Laravel construiu.
Passo 1: puxar o schema que as migrações do Laravel criaram
Em um projeto TypeScript novo, instale o kastor e o driver do seu banco. A maioria das aplicações Laravel roda MySQL ou MariaDB; PostgreSQL funciona do mesmo jeito com pg.
npm install kastor-orm mysql2
npx kastor-orm init --db mysql
npx kastor db pull --out src/db/schema.tsAntes do pull, aponte o DATABASE_URL do .env criado pelo init para o banco do Laravel, por exemplo mysql://forge:secret@127.0.0.1:3306/laravel. O db pull lê tabelas, colunas, defaults, índices e chaves estrangeiras criadas pelo php artisan migrate e escreve definições table() com as relações deduzidas das chaves. O Laravel usa snake case nas colunas, então o schema mantém os nomes do banco com .map() e o TypeScript lê em camelCase:
class Post extends Model
{
use HasFactory, SoftDeletes;
protected $casts = ['published' => 'boolean', 'meta' => 'array'];
public function author() { return $this->belongsTo(User::class, 'user_id'); }
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(),
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[] }>().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 comments = table('comments', {
id: t.bigId(),
postId: t.bigint().map('post_id').references(() => posts.id, { onDelete: 'cascade' }),
body: t.text(),
})
export const schema = { users, posts, comments }
export const relations = ({ users, posts, comments }: typeof schema) => ({
users: { posts: many(posts) },
posts: { author: one(users, { fields: ['userId'], references: ['id'] }), comments: many(comments) },
comments: { post: one(posts, { fields: ['postId'], references: ['id'] }) },
})O $table->id() do Laravel é um big integer sem sinal, então os ids são t.bigId() e chegam como bigint no JavaScript. É exato para qualquer valor que o MySQL guarda; converta com String(id) antes de enviar como JSON.
Passo 2: decidir quem cuida das migrações
No caminho A, o kastor assume. Registre o banco atual como primeira migração do kastor e faça cada mudança em src/db/schema.ts:
npx kastor migrate baseline --name init
npx kastor migrate dev --name add_slug_to_posts
npx kastor migrate deployNo caminho B, mantenha o Laravel como dono enquanto o PHP for a aplicação principal. Rode php artisan migrate como sempre e depois npx kastor db pull para atualizar o schema TypeScript, com commit junto da mudança. Não rode kastor migrate dev nesse banco enquanto o Laravel cuidar dele. O Laravel guarda o histórico na tabela migrations e o kastor na _kastor_migrations; uma nunca mexe na outra, então trocar de dono depois é seguro.
Passo 3: do Eloquent para o kastor, conceito por conceito
| Eloquent e Laravel | kastor |
|---|---|
class Post extends Model | table('posts', ...) e tipos por InferSelect |
$fillable, $casts | os tipos de create e update, t.boolean(), t.json<T>() |
belongsTo, hasOne, hasMany | one() e many() |
belongsToMany com tabela pivot | many(tags, { through: postTag }) |
scopePublished | um objeto where tipado |
with(), withCount(), whereHas() | include, _count, some |
SoftDeletes, withTrashed, restore | softDelete, withDeleted: true, restore |
paginate(), cursorPaginate() | take e skip com count, ou findPage |
updateOrCreate, increment | upsert, { increment: 1 } |
DB::transaction, DB::select | db.$transaction, db.$sql |
php artisan migrate, migrate:fresh --seed | npx kastor migrate deploy, migrate reset |
O eager loading é onde o kastor fica mais parecido com o Eloquent, com uma vantagem: o with() roda uma consulta extra por relação, enquanto o kastor carrega a árvore inteira em uma instrução por chamada.
import { db } from '../db'
// Era: scopePublished
const published = { published: true } as const
export const latest = () =>
db.posts.findMany({
where: { ...published, comments: { some: {} } },
include: { author: { select: { id: true, name: true } }, _count: { select: { comments: true } } },
orderBy: { createdAt: 'desc' },
take: 20,
})Eventos de model e observers não têm equivalente. Coloque essa lógica na função que grava, onde o TypeScript confere tudo. Accessors como getFullNameAttribute viram funções comuns sobre o tipo da linha.
Passo 4: seeders e factories
A chave seed do kastor.config.ts aponta para um arquivo que faz o papel do DatabaseSeeder, e factory() cobre as model factories, inclusive registros aninhados como o 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: `Usuário ${seq}`, email: `user${seq}@example.com` }))
// Era: User::factory()->count(10)->has(Post::factory()->count(3))->create()
await users.createMany(10, () => ({
posts: { create: [1, 2, 3].map((n) => ({ title: `Post ${n}` })) },
}))
})Caminho B na prática: dividindo o banco com o Laravel
- Timestamps. O Eloquent preenche
created_ateupdated_at. No kastor,defaultNow()preenche o primeiro na criação e.updatedAt()preenche o segundo em cada create e update, mantendo a mesma convenção. - Soft deletes. Declare
softDelete: 'deletedAt'nas tabelas que usam o trait, para que as leituras em TypeScript ignorem registros na lixeira como o Eloquent faz. - Autenticação. As sessões do Laravel são criptografadas com a
APP_KEYe difíceis de compartilhar. Tokens são mais simples: o Sanctum guarda um hash SHA-256 de cada token, então a API TypeScript consegue validar os tokens emitidos pelo Laravel. - Conexões. Os workers do PHP-FPM e o pool do Node seguram conexões. Mantenha a soma abaixo do
max_connectionsdo servidor. - Relações polimórficas.
morphToguarda um nome de classe e um id sem chave estrangeira; consulte essas colunas com filtros diretos ou com$sql. - Hashes de senha. O Laravel grava bcrypt com o prefixo
$2y$. Confira se a biblioteca bcrypt que você escolher no Node aceita esse prefixo antes de portar o login.
A versão em inglês deste guia traz a tabela completa de equivalências e um exemplo de validação de tokens do Sanctum em TypeScript. Para o resto do fluxo, veja o guia de migrações de banco de dados.
Perguntas frequentes
Q1Dá para usar o kastor dentro de um projeto Laravel?
Não. O kastor é uma biblioteca TypeScript e roda em runtimes JavaScript: Node.js, Bun, Deno e plataformas edge. Uma aplicação PHP continua usando o Eloquent. O que dá para fazer é rodar um serviço TypeScript ao lado do Laravel no mesmo banco, ou levar um serviço para TypeScript.
Q2Laravel e uma API Node.js podem dividir o mesmo banco?
Sim. Rode npx kastor db pull no banco do Laravel para ter um schema tipado, escolha um lado para cuidar das migrações e siga as convenções do Laravel no lado TypeScript: created_at, updated_at, deleted_at e os mesmos formatos JSON das colunas com cast.
Q3Qual é o equivalente do with() do Eloquent?
include. Post::with(['author', 'tags'])->withCount('comments') vira db.posts.findMany({ include: { author: true, tags: true, _count: { select: { comments: true } } } }), carregado em uma única instrução por chamada.
Q4Tem seeders e factories?
Sim. Um arquivo de seed exporta defineSeed(async (db) => ...) e roda com npx kastor db seed. factory(db.users, ({ seq }) => dados) cria registros de teste, e npx kastor migrate reset faz o papel do migrate:fresh --seed.