DEV Community

Cover image for Eloquent's Magic Is Gone. Drizzle Makes You Name Column Types.
Gabriel Anhaia
Gabriel Anhaia

Posted on

Eloquent's Magic Is Gone. Drizzle Makes You Name Column Types.


A Laravel developer types $user->posts->first()->title and IDE autocomplete fills in the rest. PHPStan with Larastan groans about mixed until they paste a @property block at the top of the model. The code runs anyway, because Eloquent will look at the table at runtime and figure it out.

A TypeScript developer using Drizzle types user.posts[0].title and the editor knows the type before the file is saved. Not because of a comment block. Because Drizzle compiled the schema declaration into a TS type the moment the file was imported. There is no runtime guess, and no introspection on a connection that is not open yet.

Same business question. Different floors of the building.

The PHP developer learning TypeScript usually arrives at this comparison expecting to discover that TS is "just typed PHP." Eloquent and Drizzle are where that expectation falls apart hardest. They are not two flavours of the same idea. One is convention-first; one is schema-first. The mental model shift is the part that takes a week longer than learning the syntax.

What Eloquent's "Magic" Actually Is

User::where('email', $x)->first() is four method calls and three layers of indirection. The first one is a static call on a model class that does not have a where method. The model defines no static where. The call is intercepted by __callStatic, forwarded to a fresh query builder, and from there into the connection's grammar. The return is an Eloquent\Builder, which the next chained call narrows or terminates.

When first() returns, the static analyser is asked: what is the type of this thing? In stock Laravel, the docblock says \Illuminate\Database\Eloquent\Model|null. Not User. Not User|null. The base model. Larastan knows about this footgun and has extensions to refine the return type, but the refinement is opt-in and depends on the relation generics being declared correctly. Custom builders in particular have a long trail of inference issues that require model-by-model annotation to resolve.

The properties on the returned model are the same story. $user->email is not a defined PHP property. User extends Model, which implements __get, which checks the loaded attribute bag, which was hydrated from a row that the database returned, which had columns whose names matched whatever the database said. The IDE has no way to know what email is unless you wrote a /** @property string $email */ block by hand, or generated one with barryvdh/laravel-ide-helper and remembered to regenerate it after every migration.

That is the convention-first deal. Write less. Trust the runtime. The static layer plays catch-up, and a strong static layer (Larastan at level 8, IDE Helper, generic relation annotations on every method) gets you most of the way back. But the schema is not the source of truth. The migration is. So is the database. The model is a runtime negotiation between them, and the type information is a hand-maintained shadow file.

What Schema-First Buys You

Drizzle inverts the deal. You write the schema in TypeScript, and the table type is the schema type. The shadow file disappears because the file is the schema.

// schema.ts
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  authorId: integer('author_id')
    .references(() => users.id)
    .notNull(),
  title: text('title').notNull(),
  body: text('body').notNull(),
  publishedAt: timestamp('published_at'),
})
Enter fullscreen mode Exit fullscreen mode

That declaration is not a hint. It is a value. From it, Drizzle derives both the runtime SQL DDL (via drizzle-kit migrations) and the TypeScript types you query against. The inferred row type is available for free:

import { type InferSelectModel, type InferInsertModel } from 'drizzle-orm'
import { users } from './schema'

type User = InferSelectModel<typeof users>
// { id: number; email: string; name: string; createdAt: Date }

type NewUser = InferInsertModel<typeof users>
// { id?: number; email: string; name: string; createdAt?: Date }
Enter fullscreen mode Exit fullscreen mode

createdAt is required on select because the column is notNull. It is optional on insert because the column has a default. id is optional on insert because it is a serial. None of those facts are written twice. They live on the column definition and propagate through type-level computation.

When you query, the result type is computed from the query.

const user = await db
  .select({ id: users.id, email: users.email })
  .from(users)
  .where(eq(users.email, email))
  .limit(1)
// user: { id: number; email: string }[]
Enter fullscreen mode Exit fullscreen mode

The picked subset narrows the type. The .limit(1) does not change the array nature, because Drizzle does not pretend a SQL LIMIT 1 returns a single row at the type level. If you want one row or undefined, you ask for it explicitly with the relational query API or with .then(rows => rows[0]). There is no inference fiction.

The current major-line release as of writing is on the v1.0 beta track. Across the v1 betas, the team has shipped JIT row mappers, MSSQL support in drizzle-orm and drizzle-kit, and the relational queries API rewritten as RQBv2; the most recent beta at the time of writing was a narrower Windows-migrations fix. Schema-first did not start with Drizzle. Prisma was the louder example for years. The v1 push is the first time the TypeScript-native, no-runtime-engine pitch has had a stable release shape behind it.

The Same Question, Both Floors

Pick a real query. "Find the most recent five posts by users in the marketing team, with the author email attached." The Eloquent version, assuming the relations are declared:

// app/Models/User.php
class User extends Model
{
    /** @return HasMany<Post> */
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class, 'author_id');
    }

    /** @return BelongsToMany<Team> */
    public function teams(): BelongsToMany
    {
        return $this->belongsToMany(Team::class);
    }
}

// the query
$posts = Post::query()
    ->whereHas('author.teams', function ($q) {
        $q->where('name', 'marketing');
    })
    ->with('author:id,email')
    ->latest('published_at')
    ->limit(5)
    ->get();

foreach ($posts as $post) {
    echo $post->title;
    echo $post->author->email;
}
Enter fullscreen mode Exit fullscreen mode

Reads like English. Will run on the database with one query for the posts and one for the authors, eager-loaded. The static inference of $post->author->email depends on two things. First, Larastan extensions reading the BelongsTo annotation on Post::author(), which you remembered to write. Second, the @property string $email block on the User model, which IDE Helper generated for you and you did not commit stale.

When that toolchain is set up correctly, the developer experience is excellent. When it is not, the analyser's mixed shows up and the runtime takes over. The failure modes are familiar: a junior added a column three sprints ago and never regenerated the helper, a custom builder is in play, whereHas is one Closure deeper than Larastan can resolve. The runtime is forgiving. Production sees the cost.

Now the same query in Drizzle:

import { db } from './db'
import { posts, users, teams, usersToTeams } from './schema'
import { eq, and, desc } from 'drizzle-orm'

const recentMarketingPosts = await db
  .select({
    id: posts.id,
    title: posts.title,
    publishedAt: posts.publishedAt,
    author: {
      id: users.id,
      email: users.email,
    },
  })
  .from(posts)
  .innerJoin(users, eq(users.id, posts.authorId))
  .innerJoin(usersToTeams, eq(usersToTeams.userId, users.id))
  .innerJoin(teams, eq(teams.id, usersToTeams.teamId))
  .where(and(eq(teams.name, 'marketing')))
  .orderBy(desc(posts.publishedAt))
  .limit(5)

for (const row of recentMarketingPosts) {
  row.title       // string
  row.author.email // string
}
Enter fullscreen mode Exit fullscreen mode

Wordier. Closer to the SQL. The shape of the result is the shape of the select({ ... }) object literal. The compiler will refuse to ship if you reference row.author.username because no username field was selected and no username column exists on users. No mixed, no docblock, no IDE helper to regenerate.

You are reading more SQL on the page in exchange for the runtime not lying about types.

The Schema-First Dividend

Once the schema is the source of truth, three things stop being separate problems.

Migrations stop being a separate language. drizzle-kit generate reads the same schema file you query against and emits SQL migrations from a diff between your declaration and the introspected database state. A second representation is not needed. In Eloquent, database/migrations is one source ("up the schema") and app/Models is another ("describe the schema for the runtime"), and keeping them aligned is a manual chore that grows with every column.

Validators stop being hand-written, too. Drizzle has official zod and valibot integrations that derive an input schema from the table definition. The validator narrows in lockstep with the column types: change the column to varchar({ length: 80 }) and the inferred zod schema picks up the max(80) constraint at the next compile.

import { createInsertSchema } from 'drizzle-zod'
import { users } from './schema'

const insertUserSchema = createInsertSchema(users, {
  email: (s) => s.email(),
})

// this throws at request time with a typed error
const newUser = insertUserSchema.parse(req.body)
Enter fullscreen mode Exit fullscreen mode

You wrote the schema once. You got the row type, the insert type, the migration, and the request validator from the same file. In the Laravel world, the equivalent would be: a migration, an Eloquent model with $fillable and $casts and @property annotations, a Form Request with rules, and a separate transformer for the API. Four places to update when a column changes.

Refactors stop being archaeology. Renaming a column in Drizzle is a compile error in every file that touched it. Renaming a column in Eloquent is a runtime null where the IDE used to show a value, and the path to noticing it depends on whether the test that exercised that field was checking that field or just checking that the response was 200.

This is the dividend the PHP migrant feels first. Not "TypeScript is faster" or "Node has more libraries." It is: the type system has a relationship with the database, and that relationship is checked by the compiler instead of policed by a watchful team.

The Other Side: Where Eloquent Still Wins

Schema-first is not free. Pretending otherwise is the kind of comparison that makes converts but loses arguments.

Prototyping velocity. Eloquent will let you scaffold a Post resource with a controller, a model, and a migration in three artisan commands and have a CRUD endpoint working in twenty minutes. Drizzle will not. You write the schema, you write the validator, you wire the route, you handle the response shape. There is no php artisan make:resource for the TS world that hands you the same speed without giving up the type safety. The trade is real.

Mass assignment ergonomics. $user->update($request->validated()) is one line. The Drizzle equivalent is an explicit object literal with the fields you allow, or a partial type derived from the insert schema and intersected with the request DTO. PHP's array-as-untyped-record is a feature here, not a bug, and Laravel's $fillable/$guarded plus $request->validated() covers the safety story without ceremony.

Factories and seeders. Laravel factories with the factory bloodline pattern, like User::factory()->hasPosts(3)->create(), produce realistic data graphs in one line. Drizzle has drizzle-seed shipping in v1, but it is younger than the Eloquent factory story and the patterns are still settling.

Polymorphic relations. Eloquent's morphs (commentable, taggable) are first-class. Drizzle does not bake them into the schema DSL; you model them with discriminated unions and check the discriminator manually. If your domain leans hard on polymorphism, Eloquent saves you a chapter of code.

The community floor. A Laravel developer with a stuck query can search Stack Overflow for fifteen years of accumulated answers. The Drizzle ecosystem is two years old. If your team is one senior and four juniors and the seniors will not be doing the answering, that gap matters.

The honest framing is that Drizzle and Prisma 7, the latter now a pure-TypeScript ORM after the Rust engine was removed in the January 2026 GA, are paying a dev-velocity cost at the prototyping edge to recover it later as the codebase grows. Eloquent paid the type-safety cost up front and asks Larastan to cover it after the fact. Neither team is wrong about which cost is worse. They are answering different questions.

What the Rewiring Feels Like

The first week, the PHP developer in TypeScript types user.email and waits for the compiler to find it for them. It does not. They have to import users, build a select, run the query, await it, and read off the array. The reflexive feeling is that TS is verbose. The diagnosis is that PHP was writing a lot of code for them inside __get and they had stopped counting it.

The second week, the same developer changes a column from text to varchar({ length: 80 }) and watches eleven files turn red in the editor. Every place that wrote a longer string is now an error. The instinct is to be annoyed. The realisation is that Larastan would have shrugged at every one of those eleven files because the model property was string, the DB column was a runtime concern, and nobody told the static analyser the two were related.

By the time they have a month in, they stop reaching for Builder|Model|null patterns and start writing select objects that match the shape they need. They notice that the data flow is one direction now: schema decides the type, query narrows the type, code consumes the narrowed type. No model class plays both roles, and the facade gymnastics are gone with it. The HTTP boundary is a typed validator. The DB boundary is a typed schema. The middle is a function that calls the database and returns the shape it asked for.

That is the rewiring. Not "TS instead of PHP." Schema-as-truth instead of convention-as-truth. The data layer goes from a thing the runtime negotiates into a thing the compiler enforces.

Forward Motion

If you are a Laravel developer evaluating TypeScript for the next service, the move is not to translate Eloquent into Drizzle line for line. It will feel hostile if you do. The move is to rebuild the mental model: the schema file is now the canonical place a column lives; the query is now a small object describing what shape you want back; the validator is now derived, not hand-rolled.

Eloquent is not going away. Larastan keeps closing the inference gap, and the framework's velocity for the right shape of project is still its biggest selling point. Drizzle is not the only TypeScript answer either; Prisma 7 is the other live track, and the two will keep pulling each other forward. The PHP-to-TS migration is not a verdict against Eloquent. It is a step into a different floor of the same building, where the type system is part of the load-bearing structure instead of a comment block on top of it.

Get used to writing the schema first. The rest of the rewiring follows.


If this reframing landed, PHP to TypeScript is the book it came from. The data-layer chapter walks the Eloquent-to-Drizzle move end to end, including the validator, migration, and seeder story. There are also chapters on the async rewiring, generics, and discriminated unions for the bits Laravel did not prepare you for.

It is one of five books in The TypeScript Library:

  1. TypeScript Essentials — entry point. Types, narrowing, modules, async, daily-driver tooling.
  2. The TypeScript Type System — deep dive. Generics, mapped/conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — bridge for JVM developers. Variance, null safety, sealed→unions, coroutines→async/await.
  4. PHP to TypeScript — bridge for PHP 8+ developers. Sync→async paradigm, generics, discriminated unions.
  5. TypeScript in Production — production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

Books 1 and 2 are the core path. Books 3 and 4 substitute for them if you speak JVM or PHP. Book 5 is for anyone shipping TS at work.

The TypeScript Library — 5-book collection

Top comments (0)