architecture

Prisma as the single source of truth: generating GraphQL types, Zod schemas, and Eloquent models from one schema

How we structured Prisma in plato-next to drive code generation across five outputs, TypeScript client, Pothos GraphQL types, Zod validation, Eloquent models, and test data, and what that buys a polyglot stack.

plato-next is a polyglot system. The read API is GraphQL (TypeScript, Pothos, GraphQL Yoga). The write API is Laravel (PHP). The frontend is Next.js. They all need to agree on what a Customer or a Trail or a Notification looks like.

The naive way to handle this: define the model in each layer independently. TypeScript interface here, Zod schema there, PHP class somewhere else. This works until it doesn’t, and it stops working slowly, via drift, not via a single visible failure.

The approach we took in plato-next: make Prisma the source of truth for the data layer and derive everything else from it. One schema, multiple generated outputs, one command to regenerate everything after a change.

The schema structure

The schema isn’t a single file. plato-next uses Prisma’s multi-file schema, which lets you split models into domain-focused files:

packages/database/prisma/
  schema.prisma      , datasource + all generator configs
  models/
    customer.prisma  , Customer, CustomerLoyalty, CustomerLoyaltyTransaction
    call.prisma      , Call, CallLog
    chat.prisma      , Chat, ChatMessage
    trail.prisma     , Trail, TrailStatus
    payment.prisma   , Payment, Refund
    notification.prisma
    allocation.prisma
    ... (20+ model files)
  migrations/
  seed/

Each domain gets its own file. Engineers working on the payment flow don’t need to scroll past 2,000 lines of unrelated models to find Payment. The generator still processes them as a single unified schema.

Here’s what customer.prisma looks like:

model Customer {
  id            Int      @id @default(autoincrement())
  email         String   @unique
  salutation    String
  first_name    String
  last_name     String
  country_code  String
  phone         String
  travel_count  Int      @default(0)
  is_hni        Boolean  @default(false)
  is_high_touch Boolean  @default(false)
  created_at    DateTime
  updated_at    DateTime

  // Relations
  calls               Call[]               @relation("CustomerCalls")
  trail               Trail[]              @relation("CustomerTrails")
  passengers          Passenger[]          @relation("CustomerPassenger")
  customerAttachments CustomerAttachment[] @relation("CustomerAttachment")

  @@map("customers")
}

The @@map("customers") directive maps the Prisma model name to the actual table name. This matters because the codegen needs to produce PHP models that reference customers as the table, not Customer.

Five generators, one schema

The schema.prisma file configures five generators that run when you call turbo db:generate:

// 1. Standard Prisma client, TypeScript with full type safety
generator client {
  provider        = "prisma-client"
  output          = "../src/generated/client"
  previewFeatures = ["relationJoins", "fullTextSearchPostgres", "typedSql", "strictUndefinedChecks"]
}

// 2. Zod schemas for validation (both TypeScript + Laravel API validation)
generator zod {
  provider          = "prisma-zod-generator"
  output            = "../../zod-schemas/src"
  isGenerateSelect  = true
  isGenerateInclude = true
}

// 3. JSON type definitions for unstructured JSON columns
generator json {
  provider  = "prisma-json-types-generator"
  namespace = "PrismaJson"
  allowAny  = false
}

// 4. Fake data factory for tests
generator fake_data {
  provider = "prisma-generator-fake-data"
  output   = "../src/generated/types/fake-data.ts"
}

// 5. Pothos types for GraphQL schema generation
generator pothos {
  provider     = "prisma-pothos-types"
  output       = "../src/generated/pothos/generated.ts"
  clientOutput = "../client"
}

// 6. Eloquent models for Laravel
generator eloquent {
  provider = "prisma-eloquent-generator"
  output   = "../../eloquent/src/Models"
}

That’s six generators in practice (client + five additional). When a schema change goes through, a single turbo db:generate propagates it everywhere.

How Pothos uses the generated types

The Pothos GraphQL types are how the read API stays in sync with the schema without manual type maintenance.

// apps/graphql/src/account.ts
import { builder, prisma } from "@repo/db";

const Notification = builder.prismaObject("Notification", {
  fields: (t) => ({
    id: t.exposeID("id"),
    user_id: t.exposeInt("user_id"),
    type: t.exposeString("type"),
    read: t.exposeBoolean("read"),
    created_at: t.expose("created_at", { type: "DateTime" }),
    message: t.string({
      resolve: (n) => JSON.stringify(n.message),
    }),
  }),
});

builder.queryFields((t) => ({
  myNotifications: t.prismaField({
    type: [Notification],
    args: {
      read: t.arg.boolean({ required: false }),
    },
    resolve: async (query, _root, args, ctx) => {
      const context = ctx as unknown as GraphQLContext;
      return prisma.notification.findMany({
        ...query,
        where: {
          user_id: context.currentUser.sub,
          read: args.read ?? undefined,
        },
        orderBy: { created_at: "desc" },
      });
    },
  }),
}));

builder.prismaObject("Notification", ...) creates a GraphQL type tied to the Prisma Notification model. t.exposeID, t.exposeInt, t.exposeString, these expose fields with types inferred from the Prisma schema. If you try to expose a field that doesn’t exist in the model, TypeScript catches it at compile time, not at runtime.

t.prismaField for the query resolver is particularly useful: it receives a query argument from Pothos that contains the field selection from the GraphQL client, and passes it to Prisma via ...query. Pothos uses this to translate GraphQL field selections into Prisma include and select clauses automatically. You don’t build the Prisma query manually, you let the relationship between the GraphQL schema and the Prisma schema do that for you.

The Eloquent generator

The part I found most architecturally satisfying: generating PHP Eloquent models from the Prisma schema.

The write side of the stack is Laravel. Eloquent models define relationships, casts, fillable fields. If those are maintained manually as a separate layer, they’ll drift from the Prisma schema over time, a field gets added to the Prisma schema, someone forgets to add it to the Eloquent model’s $fillable array, and now writes silently ignore that field until someone notices.

The prisma-eloquent-generator outputs PHP model files to packages/eloquent/src/Models/. Here’s what a generated model looks like (simplified):

// packages/eloquent/src/Models/Customer.php
class Customer extends Model
{
    protected $table = 'customers';

    protected $fillable = [
        'email',
        'salutation',
        'first_name',
        'last_name',
        'country_code',
        'phone',
        'travel_count',
        'is_hni',
        'is_high_touch',
        'created_at',
        'updated_at',
    ];

    protected $casts = [
        'is_hni'        => 'boolean',
        'is_high_touch' => 'boolean',
        'travel_count'  => 'integer',
    ];

    public function calls(): HasMany
    {
        return $this->hasMany(Call::class, 'customer_id');
    }

    public function trails(): HasMany
    {
        return $this->hasMany(Trail::class, 'customer_id');
    }
}

The $fillable array is correct. The $casts are correct. The relationship methods are generated from the Prisma relation definitions. Domain-specific behavior, scopes, custom accessors, business logic methods, gets added to subclasses or traits that extend the generated model. The generated class is the structural baseline; you don’t hand-edit it.

What this actually changes

Before this setup, adding a new column to a table involved four separate steps, each in a different language or layer: the Prisma migration (TypeScript), the GraphQL type (TypeScript), the Zod validation schema (TypeScript), and the Eloquent model (PHP). Each step required finding the right file and making sure the change matched the others.

With the generator setup, it’s two steps: update the Prisma schema, run turbo db:generate. The migration is still manual (intentionally, auto-migrations are risky). Everything else propagates.

The team’s day-to-day feels different. Schema changes are low-friction. There’s no mental overhead of “did I remember to update the Zod schema?” The TypeScript compiler will tell you if a resolver is using a field that no longer exists in the generated Prisma types. The PHP generator will include the new field in $fillable. The Pothos types will have the new field available for exposure in the GraphQL schema.

What doesn’t get generated

Schema-as-source-of-truth is not “generate everything.” There’s a category of things that correctly remain manual:

Authorization logic. Whether a user can read a Customer record is not derivable from the model structure, it depends on business rules. Pothos resolvers handle this explicitly via context checks.

Complex write workflows. A checkout flow that involves creating a Trail, charging a Payment, sending a confirmation email, and updating CustomerLoyalty points is a multi-step business process. The generator can give you type-safe models for each of those, but the orchestration logic is Laravel application code.

Custom Eloquent behavior. Scopes like whereHighTouch(), custom query builders, domain-specific accessors, these live in Eloquent traits that extend the generated models. The generator creates the structure; you add the behavior.

The distinction that works: generate what’s structural repetition, write what requires judgment. The generator removes the mechanical work of keeping layers in sync. It doesn’t try to understand your business logic.

The tradeoff: generator maintenance

Running five generators from a single schema works well until a generator breaks.

We’ve hit this twice. Once when a Prisma minor version changed an internal API that prisma-eloquent-generator depended on, the generator failed silently on new models for a few days before someone noticed the generated file hadn’t updated. Once when prisma-zod-generator produced output with a circular type reference that TypeScript rejected.

Both were fixed by updating the generator version. But it added a new thing to watch: the generator ecosystem needs maintenance alongside the schema it reads from. If you npm audit your app packages but forget about your code generation dependencies, you can end up with a stale generator producing stale output.

The mitigation we added: a CI step that runs turbo db:generate and then checks whether the git diff is empty. If it’s not, the generation output is stale and the build fails. That catches any case where someone updated the schema but didn’t run the generator.

Running it

The command is simple from the developer’s perspective:

turbo db:generate   # runs all generators
turbo db:migrate    # runs migrations (dev environment)
turbo db:deploy     # applies migrations (production)

The complexity lives in the generator configuration and the CI validation step. The interface the developer sees is a single command that keeps the whole stack in sync.

That’s the outcome: schema drift eliminated not by discipline but by automation.