Stacks Models
Key Paths
- Your models:
app/Models/ (create it; it does not exist in a fresh project)
- Built-in models:
storage/framework/defaults/app/Models/ (62 files, grouped
into commerce/, Content/, realtime/ and a flat top level)
ModelOptions / Attribute types: storage/framework/core/types/src/model.ts
- Attribute presets:
storage/framework/types/attributes.ts
To customize a built-in model, create the same filename under app/Models/ -
app/Models/User.ts wins over the default. buddy publish:model User copies the
default across as a starting point.
Writing a model
Everything - schema, validation, factory, relationships, behavior - is declared
in one defineModel() call. Migrations are derived from this; you do not write
the SQL.
// app/Models/Product.ts
import { defineModel } from '@stacksjs/orm'
import { schema } from '@stacksjs/validation'
export default defineModel({
name: 'Product', // defaults to the file name
table: 'products', // defaults to lowercase plural of `name`
primaryKey: 'id', // default
autoIncrement: true, // default
traits: {
useUuid: true,
useTimestamps: true,
useApi: { uri: 'products', routes: ['index', 'store', 'show', 'update', 'destroy'] },
useSearch: { searchable: ['name'], filterable: ['status'] },
observe: true,
},
belongsTo: ['Category'],
hasMany: ['Review'],
attributes: {
name: {
required: true,
fillable: true,
order: 1,
validation: {
rule: schema.string().min(3).max(100),
message: { max: 'Name must have a maximum of 100 characters' },
},
factory: faker => faker.commerce.productName(),
},
status: {
required: true,
fillable: true,
default: 'draft',
validation: { rule: schema.enum(['draft', 'published', 'archived']) },
factory: faker => faker.helpers.arrayElement(['draft', 'published', 'archived']),
},
},
} as const)
as const is what the built-in models use - it narrows literal types so the
generated model types stay precise.
Attribute fields
validation.rule is the only required key on an attribute.
| Field |
Effect |
required |
Value required; emits a NOT NULL column |
nullable |
Explicit nullability override |
default |
Column default (string | number | boolean | Date) |
unique |
Unique constraint |
type |
Force the column type instead of inferring from the rule |
order |
Column order in the table and in dashboard forms |
fillable |
Allow mass assignment |
guarded |
Block mass assignment |
hidden |
Exclude from JSON serialization (passwords, tokens) |
foreignKey |
Disable, infer, or configure the FK constraint |
factory |
(faker) => value, used by seeders and tests |
validation |
{ rule, message? } - rule from schema, message keyed by rule name |
Traits
| Trait |
What it adds |
useUuid |
UUID column alongside the primary key |
useTimestamps (alias timestampable) |
created_at / updated_at. On by default |
useSoftDeletes (alias softDeletable) |
deleted_at plus soft-delete query scopes |
useAuth (alias authenticatable) |
Auth columns; { usePasskey: true } adds passkeys |
useApi |
Generates REST actions and routes: { uri, routes } |
useSearch (alias searchable) |
Search-engine indexing: { displayable, searchable, sortable, filterable } |
useSocials |
OAuth identities, e.g. ['github'] |
useActivityLog |
Writes an Activity row per change |
observe |
Emits {model}:created / :updated / :deleted events |
billable |
Stripe methods (checkout(), activeSubscription(), ...) |
taggable / categorizable / commentable / likeable |
Pivot tables and their relation methods |
Also at the top level: indexes: [{ name, columns, unique?, where? }] for
composite and partial-unique indexes, and dashboard: { highlight: true } to
feature the model in the admin UI.
Relationships
hasOne, hasMany, belongsTo, belongsToMany, hasOneThrough,
hasManyThrough, morphOne, morphMany, morphTo, morphToMany,
morphedByMany. Each takes an array of model names, or an object form when you
need to name the foreign key.
Computed properties and scopes
get: {
fullName: (model) => `${model.firstName} ${model.lastName}`,
},
set: {
password: (value) => makeHash(value),
},
scopes: {
published: (query) => query.where('status', 'published'),
},
Workflow
buddy make:model Product # scaffold app/Models/Product.ts
buddy generate:migrations # diff models against the schema, emit SQL
# review the generated file in database/migrations/
buddy migrate # apply it
buddy migrate:fresh --seed # dev only: drop, re-migrate, seed
Models resolve at runtime through createModel() from bun-query-builder -
there is no build step between editing a model and querying it. Only migrations
need generating.
Seeding
Seed data is declared on the model, through the useSeeder trait plus the
per-attribute factory functions:
traits: {
useSeeder: {
count: 20,
// Optional: pin specific rows over the generated ones. Keys use the
// model's camelCase attribute names.
fixtures: [
{ name: 'Flagship Widget', status: 'published' },
],
},
},
buddy seed walks every model carrying the trait and fills its table from the
attribute factories. Nothing else is needed - no seeder files, no registration.
buddy seed # every model with a useSeeder trait
buddy seed --fresh # truncate each table first
buddy seed --only Product,Review # just these models
buddy seed --except User # everything but these
buddy seed --include-defaults # framework built-ins too
A model with no useSeeder trait is never seeded. Auth and OAuth models are
skipped on a non-fresh database so re-seeding cannot invalidate live sessions -
pass --allow-protected to override.
All 62 built-in models by category
Users & Auth
- User — name, email, password | traits: useAuth(passkey), useUuid, useTimestamps, useSocials(github) | hasOne: Subscriber, Driver, Author | hasMany: PersonalAccessToken, Customer
- Author — name, email | belongsTo: User | hasMany: Post
- Customer — name, email, phone, totalSpent, lastOrder, status, avatar | belongsTo: User | hasMany: Order, GiftCard, Review, Payment
- Driver — name, phone, vehicleNumber, license, status | belongsTo: User | hasMany: DeliveryRoute
- Subscriber — email, status, source | belongsTo: User | hasMany: SubscriberEmail
Content
- Post — title, content, poster, excerpt, views, publishedAt, status, isFeatured | belongsTo: Author | traits: categorizable, taggable, commentable | seeder: 20
- Page — similar to Post with taggable, categorizable
- Comment — author info, approval, content fields
- Tag — name(unique), slug(unique), description, postCount, color | seeder: 15
- Category — name, description, slug, imageUrl, isActive, parentCategoryId, displayOrder | hasMany: Product | seeder: 10
Commerce (20+ models)
- Product — name(max100), description, price(min1), imageUrl, isAvailable, inventoryCount, preparationTime, allergens(JSON), nutritionalInfo(JSON) | belongsTo: Category, Manufacturer | hasMany: Review, ProductUnit, ProductVariant, LicenseKey, WaitlistProduct, Coupon | seeder: 10, dashboard: highlighted
- ProductVariant — SKU, options, pricing
- ProductUnit — unit-specific pricing
- Cart — status(active|abandoned|converted|expired), totalItems, subtotal, taxAmount, discountAmount, total, expiresAt, currency(USD), notes | hasMany: CartItem | belongsTo: Customer, Coupon
- CartItem — quantity(min1), unitPrice, totalPrice, taxRate, taxAmount, discountPercentage, productName, productSku | belongsTo: Cart
- Order — status, totalAmount, taxAmount, discountAmount, deliveryFee, tipAmount, orderType(DINE_IN|TAKEOUT|DELIVERY), deliveryAddress, specialInstructions | hasMany: OrderItem, Payment | belongsTo: Customer, Coupon | observe: true | seeder: 20
- OrderItem — quantity(min1), price(min0), specialInstructions | belongsTo: Order, Product
- Coupon — code(unique), discountType(fixed_amount|percentage), discountValue, minOrderAmount, usageLimit, usageCount, startDate, endDate | seeder: 15
- GiftCard — code(unique), initialBalance, currentBalance, currency, status, recipientEmail, isDigital, isReloadable, expiryDate | seeder: 20
- Manufacturer — manufacturer info
- Review — rating(1-5), title, content(max2000), isVerifiedPurchase, isApproved, isFeatured, helpfulVotes, unhelpfulVotes | belongsTo: Product, Customer | seeder: 50
Shipping & Delivery
- ShippingMethod, ShippingRate (weightFrom, weightTo, rate), ShippingZone
- DeliveryRoute — driver, vehicle, stops, totalDistance | belongsTo: Driver
- DigitalDelivery — name, downloadLimit, expiryDays, automaticDelivery
- LicenseKey — key(XXXX-XXXX-XXXX-XXXX-XXXX), template, expiryDate, status
Payments & Financial
- Payment — amount, method(creditCard|debitCard|paypal|...), status(pending|completed|failed|refunded), currency, transactionId(unique) | belongsTo: Order, Customer | seeder: 50
- PaymentMethod, PaymentProduct, PaymentTransaction
- Subscription — type, providerId, providerStatus, unitPrice
- Transaction — standard transaction tracking
- TaxRate — name, rate(0-100), type(VAT|GST|Sales Tax|Customs Duty), country, region, isDefault
Engagement & Marketing
- Notification — type, channel, recipient, subject, body, status(pending|sent|delivered|failed|read) | belongsTo: User | seeder: 30
- Campaign — name, type(email|sms|push|social|multi-channel), status, audienceSize, openRate, clickRate, budget | seeder: 10
- Activity — type, description, subjectType, subjectId, causer, properties(JSON), ipAddress | belongsTo: User | seeder: 50
- EmailList, SocialPost, LoyaltyPoint (walletId, points, source, expiryDate), LoyaltyReward
System
- Job — queue, payload, attempts, available_at, reserved_at | seeder: 15
- FailedJob — failed background jobs
- Error — type, message, stack, status, additionalInfo | seeder: 10
- Log — application logs
- Request — method, path, statusCode, durationMs, ipAddress, memoryUsage, userAgent, errorMessage | seeder: 50
- Websocket — connection tracking
- PrintDevice — name, location, terminal, lastPing, printCount, isActive
- WaitlistProduct, WaitlistRestaurant — waitlist tracking
- Receipt — receipt records
CLI Commands
buddy make:model [name] — scaffold a model in app/Models/
buddy publish:model [name] — copy a built-in model into app/Models/ to override it
buddy generate:migrations — diff models against the schema and emit SQL
buddy migrate / buddy migrate:fresh --seed — apply migrations
buddy make:migration [name] — hand-write a migration instead
buddy make:factory [name] — standalone factory
buddy seed — seed every model carrying a useSeeder trait
Gotchas
- No code generation step for models.
defineModel() calls createModel()
from bun-query-builder at runtime, so a model is queryable the moment you save
it. Only migrations are generated.
- Migrations come from models. Change the model, run
buddy generate:migrations,
review the SQL, then buddy migrate. Editing a generated migration by hand
will be overwritten by the next diff.
commentable, not commentables. define-model only checks the singular
key. The plural spelling used to type check while leaving the trait inert.
- Seeding is model-declared.
useSeeder sets the count and fixtures; the
per-attribute factory functions produce the values. There are no seeder
files to write or register.
hidden is serialization, guarded is mass assignment. They are different
protections; a password wants both hidden and no fillable.
validation.rule is mandatory on every attribute - it drives both request
validation and the inferred column type.
- Dashboard-highlighted models (
dashboard: { highlight: true }) appear
prominently in the admin UI.
1---2name: stacks-models-33description: Use when working with data models in Stacks — the defineModel() API, model attributes with validation and factories, relationships (hasOne/hasMany/belongsTo/belongsToMany), traits (useAuth, useUuid, useTimestamps, useSearch, useApi, billable, taggable, categorizable, commentable, likeable, observe), computed properties (get/set), model generation, and the 50+ built-in framework models. Covers model definitions and storage/framework/defaults/app/Models/.4license: MIT5---67# Stacks Models89## Key Paths10- Your models: `app/Models/` (create it; it does not exist in a fresh project)11- Built-in models: `storage/framework/defaults/app/Models/` (62 files, grouped12 into `commerce/`, `Content/`, `realtime/` and a flat top level)13- `ModelOptions` / `Attribute` types: `storage/framework/core/types/src/model.ts`14- Attribute presets: `storage/framework/types/attributes.ts`1516To customize a built-in model, create the same filename under `app/Models/` -17`app/Models/User.ts` wins over the default. `buddy publish:model User` copies the18default across as a starting point.1920## Writing a model2122Everything - schema, validation, factory, relationships, behavior - is declared23in one `defineModel()` call. Migrations are derived from this; you do not write24the SQL.2526```ts27// app/Models/Product.ts28import { defineModel } from '@stacksjs/orm'29import { schema } from '@stacksjs/validation'3031export default defineModel({32 name: 'Product', // defaults to the file name33 table: 'products', // defaults to lowercase plural of `name`34 primaryKey: 'id', // default35 autoIncrement: true, // default3637 traits: {38 useUuid: true,39 useTimestamps: true,40 useApi: { uri: 'products', routes: ['index', 'store', 'show', 'update', 'destroy'] },41 useSearch: { searchable: ['name'], filterable: ['status'] },42 observe: true,43 },4445 belongsTo: ['Category'],46 hasMany: ['Review'],4748 attributes: {49 name: {50 required: true,51 fillable: true,52 order: 1,53 validation: {54 rule: schema.string().min(3).max(100),55 message: { max: 'Name must have a maximum of 100 characters' },56 },57 factory: faker => faker.commerce.productName(),58 },59 status: {60 required: true,61 fillable: true,62 default: 'draft',63 validation: { rule: schema.enum(['draft', 'published', 'archived']) },64 factory: faker => faker.helpers.arrayElement(['draft', 'published', 'archived']),65 },66 },67} as const)68```6970`as const` is what the built-in models use - it narrows literal types so the71generated model types stay precise.7273### Attribute fields7475`validation.rule` is the only required key on an attribute.7677| Field | Effect |78|---|---|79| `required` | Value required; emits a `NOT NULL` column |80| `nullable` | Explicit nullability override |81| `default` | Column default (`string \| number \| boolean \| Date`) |82| `unique` | Unique constraint |83| `type` | Force the column type instead of inferring from the rule |84| `order` | Column order in the table and in dashboard forms |85| `fillable` | Allow mass assignment |86| `guarded` | Block mass assignment |87| `hidden` | Exclude from JSON serialization (passwords, tokens) |88| `foreignKey` | Disable, infer, or configure the FK constraint |89| `factory` | `(faker) => value`, used by seeders and tests |90| `validation` | `{ rule, message? }` - `rule` from `schema`, `message` keyed by rule name |9192### Traits9394| Trait | What it adds |95|---|---|96| `useUuid` | UUID column alongside the primary key |97| `useTimestamps` (alias `timestampable`) | `created_at` / `updated_at`. On by default |98| `useSoftDeletes` (alias `softDeletable`) | `deleted_at` plus soft-delete query scopes |99| `useAuth` (alias `authenticatable`) | Auth columns; `{ usePasskey: true }` adds passkeys |100| `useApi` | Generates REST actions and routes: `{ uri, routes }` |101| `useSearch` (alias `searchable`) | Search-engine indexing: `{ displayable, searchable, sortable, filterable }` |102| `useSocials` | OAuth identities, e.g. `['github']` |103| `useActivityLog` | Writes an `Activity` row per change |104| `observe` | Emits `{model}:created` / `:updated` / `:deleted` events |105| `billable` | Stripe methods (`checkout()`, `activeSubscription()`, ...) |106| `taggable` / `categorizable` / `commentable` / `likeable` | Pivot tables and their relation methods |107108Also at the top level: `indexes: [{ name, columns, unique?, where? }]` for109composite and partial-unique indexes, and `dashboard: { highlight: true }` to110feature the model in the admin UI.111112### Relationships113114`hasOne`, `hasMany`, `belongsTo`, `belongsToMany`, `hasOneThrough`,115`hasManyThrough`, `morphOne`, `morphMany`, `morphTo`, `morphToMany`,116`morphedByMany`. Each takes an array of model names, or an object form when you117need to name the foreign key.118119### Computed properties and scopes120121```ts122get: {123 fullName: (model) => `${model.firstName} ${model.lastName}`,124},125set: {126 password: (value) => makeHash(value),127},128scopes: {129 published: (query) => query.where('status', 'published'),130},131```132133## Workflow134135```sh136buddy make:model Product # scaffold app/Models/Product.ts137buddy generate:migrations # diff models against the schema, emit SQL138# review the generated file in database/migrations/139buddy migrate # apply it140buddy migrate:fresh --seed # dev only: drop, re-migrate, seed141```142143Models resolve at runtime through `createModel()` from `bun-query-builder` -144there is no build step between editing a model and querying it. Only migrations145need generating.146147## Seeding148149Seed data is declared on the model, through the `useSeeder` trait plus the150per-attribute `factory` functions:151152```ts153traits: {154 useSeeder: {155 count: 20,156 // Optional: pin specific rows over the generated ones. Keys use the157 // model's camelCase attribute names.158 fixtures: [159 { name: 'Flagship Widget', status: 'published' },160 ],161 },162},163```164165`buddy seed` walks every model carrying the trait and fills its table from the166attribute factories. Nothing else is needed - no seeder files, no registration.167168```bash169buddy seed # every model with a useSeeder trait170buddy seed --fresh # truncate each table first171buddy seed --only Product,Review # just these models172buddy seed --except User # everything but these173buddy seed --include-defaults # framework built-ins too174```175176A model with no `useSeeder` trait is never seeded. Auth and OAuth models are177skipped on a non-fresh database so re-seeding cannot invalidate live sessions -178pass `--allow-protected` to override.179180## All 62 built-in models by category181182### Users & Auth183- **User** — name, email, password | traits: useAuth(passkey), useUuid, useTimestamps, useSocials(github) | hasOne: Subscriber, Driver, Author | hasMany: PersonalAccessToken, Customer184- **Author** — name, email | belongsTo: User | hasMany: Post185- **Customer** — name, email, phone, totalSpent, lastOrder, status, avatar | belongsTo: User | hasMany: Order, GiftCard, Review, Payment186- **Driver** — name, phone, vehicleNumber, license, status | belongsTo: User | hasMany: DeliveryRoute187- **Subscriber** — email, status, source | belongsTo: User | hasMany: SubscriberEmail188189### Content190- **Post** — title, content, poster, excerpt, views, publishedAt, status, isFeatured | belongsTo: Author | traits: categorizable, taggable, commentable | seeder: 20191- **Page** — similar to Post with taggable, categorizable192- **Comment** — author info, approval, content fields193- **Tag** — name(unique), slug(unique), description, postCount, color | seeder: 15194- **Category** — name, description, slug, imageUrl, isActive, parentCategoryId, displayOrder | hasMany: Product | seeder: 10195196### Commerce (20+ models)197- **Product** — name(max100), description, price(min1), imageUrl, isAvailable, inventoryCount, preparationTime, allergens(JSON), nutritionalInfo(JSON) | belongsTo: Category, Manufacturer | hasMany: Review, ProductUnit, ProductVariant, LicenseKey, WaitlistProduct, Coupon | seeder: 10, dashboard: highlighted198- **ProductVariant** — SKU, options, pricing199- **ProductUnit** — unit-specific pricing200- **Cart** — status(active|abandoned|converted|expired), totalItems, subtotal, taxAmount, discountAmount, total, expiresAt, currency(USD), notes | hasMany: CartItem | belongsTo: Customer, Coupon201- **CartItem** — quantity(min1), unitPrice, totalPrice, taxRate, taxAmount, discountPercentage, productName, productSku | belongsTo: Cart202- **Order** — status, totalAmount, taxAmount, discountAmount, deliveryFee, tipAmount, orderType(DINE_IN|TAKEOUT|DELIVERY), deliveryAddress, specialInstructions | hasMany: OrderItem, Payment | belongsTo: Customer, Coupon | observe: true | seeder: 20203- **OrderItem** — quantity(min1), price(min0), specialInstructions | belongsTo: Order, Product204- **Coupon** — code(unique), discountType(fixed_amount|percentage), discountValue, minOrderAmount, usageLimit, usageCount, startDate, endDate | seeder: 15205- **GiftCard** — code(unique), initialBalance, currentBalance, currency, status, recipientEmail, isDigital, isReloadable, expiryDate | seeder: 20206- **Manufacturer** — manufacturer info207- **Review** — rating(1-5), title, content(max2000), isVerifiedPurchase, isApproved, isFeatured, helpfulVotes, unhelpfulVotes | belongsTo: Product, Customer | seeder: 50208209### Shipping & Delivery210- **ShippingMethod**, **ShippingRate** (weightFrom, weightTo, rate), **ShippingZone**211- **DeliveryRoute** — driver, vehicle, stops, totalDistance | belongsTo: Driver212- **DigitalDelivery** — name, downloadLimit, expiryDays, automaticDelivery213- **LicenseKey** — key(XXXX-XXXX-XXXX-XXXX-XXXX), template, expiryDate, status214215### Payments & Financial216- **Payment** — amount, method(creditCard|debitCard|paypal|...), status(pending|completed|failed|refunded), currency, transactionId(unique) | belongsTo: Order, Customer | seeder: 50217- **PaymentMethod**, **PaymentProduct**, **PaymentTransaction**218- **Subscription** — type, providerId, providerStatus, unitPrice219- **Transaction** — standard transaction tracking220- **TaxRate** — name, rate(0-100), type(VAT|GST|Sales Tax|Customs Duty), country, region, isDefault221222### Engagement & Marketing223- **Notification** — type, channel, recipient, subject, body, status(pending|sent|delivered|failed|read) | belongsTo: User | seeder: 30224- **Campaign** — name, type(email|sms|push|social|multi-channel), status, audienceSize, openRate, clickRate, budget | seeder: 10225- **Activity** — type, description, subjectType, subjectId, causer, properties(JSON), ipAddress | belongsTo: User | seeder: 50226- **EmailList**, **SocialPost**, **LoyaltyPoint** (walletId, points, source, expiryDate), **LoyaltyReward**227228### System229- **Job** — queue, payload, attempts, available_at, reserved_at | seeder: 15230- **FailedJob** — failed background jobs231- **Error** — type, message, stack, status, additionalInfo | seeder: 10232- **Log** — application logs233- **Request** — method, path, statusCode, durationMs, ipAddress, memoryUsage, userAgent, errorMessage | seeder: 50234- **Websocket** — connection tracking235- **PrintDevice** — name, location, terminal, lastPing, printCount, isActive236- **WaitlistProduct**, **WaitlistRestaurant** — waitlist tracking237- **Receipt** — receipt records238239## CLI Commands240- `buddy make:model [name]` — scaffold a model in `app/Models/`241- `buddy publish:model [name]` — copy a built-in model into `app/Models/` to override it242- `buddy generate:migrations` — diff models against the schema and emit SQL243- `buddy migrate` / `buddy migrate:fresh --seed` — apply migrations244- `buddy make:migration [name]` — hand-write a migration instead245- `buddy make:factory [name]` — standalone factory246- `buddy seed` — seed every model carrying a `useSeeder` trait247248## Gotchas249- **No code generation step for models.** `defineModel()` calls `createModel()`250 from bun-query-builder at runtime, so a model is queryable the moment you save251 it. Only migrations are generated.252- **Migrations come from models.** Change the model, run `buddy generate:migrations`,253 review the SQL, then `buddy migrate`. Editing a generated migration by hand254 will be overwritten by the next diff.255- **`commentable`, not `commentables`.** `define-model` only checks the singular256 key. The plural spelling used to type check while leaving the trait inert.257- **Seeding is model-declared.** `useSeeder` sets the count and fixtures; the258 per-attribute `factory` functions produce the values. There are no seeder259 files to write or register.260- **`hidden` is serialization, `guarded` is mass assignment.** They are different261 protections; a password wants both `hidden` and no `fillable`.262- **`validation.rule` is mandatory** on every attribute - it drives both request263 validation and the inferred column type.264- Dashboard-highlighted models (`dashboard: { highlight: true }`) appear265 prominently in the admin UI.