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. Also confers morphMany: { tokenable: 'PersonalAccessToken' }, so any authenticatable model can hold API tokens |
useApi |
Generates REST actions and routes: { uri, routes, middleware? } |
useSearch (alias searchable) |
Search-engine indexing: { displayable, searchable, sortable, filterable } |
useSocials |
OAuth identities, e.g. ['github'] |
useActivityLog |
Writes an activities feed row per change: { logOnly } / { include } / { exclude } pick the attributes |
useAudit |
Writes a model_audits row per change with an old/new diff |
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.
useApi is an API capability, not a dashboard-view generator. Its generated
routes are registered from the merged model registry. Framework defaults are
loaded first, then recursive app/Models/ definitions override matching model
names. Protect non-public resources at the model:
useApi: {
uri: 'mail-preferences',
routes: ['index', 'store', 'show', 'update', 'destroy'],
middleware: ['auth'],
}
Dashboard-specific endpoints may still use scoped Actions when their transport
shape, authorization boundary, or aggregation differs from generic CRUD. Do
not expose a sensitive model through unguarded generated routes just because a
separate dashboard endpoint is protected.
Generated store and update routes accept both spellings of every fillable
attribute and each foreign key implied by belongsTo. Declaring Product as a
belongs-to relation therefore accepts productId or product_id without
duplicating that relationship column as an attribute.
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.
The object form is also where a belongsTo says what happens to its row when
the row it points at is deleted:
belongsTo: [
{ model: 'Repository', onDelete: 'cascade' },
{ model: 'User', foreignKey: 'author_id', onDelete: 'set null' },
],
'cascade' | 'set null' | 'restrict' | 'no action', enforced by the database
on the foreign key. Left off, the default applies: the delete is refused while
a child still points at the row.
Worth declaring rather than deleting children in application code. The order
has to be right in every place that deletes, forever, and the place that misses
one leaves rows nothing can reach - while the database applies the rule to
deletes the application never made: a manual DELETE, a restore, another
service sharing the schema. Not for a polymorphic pair (commentable_id
beside commentable_type): those carry no foreign key at all, because a
constraint would name one table and reject every row pointing at another.
Use the named object form for a many-to-many relation that owns its pivot
schema. It keeps the relation accessor, migration, pivot defaults, timestamps,
and uniqueness in the model definition:
belongsToMany: {
tags: {
model: 'Tag',
table: 'taggable_models',
foreignKey: 'taggable_id',
relatedKey: 'tag_id',
pivot: {
columns: {
taggable_type: { default: 'posts' },
},
timestamps: true,
uniques: [['tag_id', 'taggable_id', 'taggable_type']],
},
},
},
An instance then exposes the named relation directly:
const post = await Post.find(id)
await post.tags().sync(tagIds)
await post.tags().detach()
The legacy array form remains supported. Prefer the named form when the pivot
has custom keys, columns, defaults, timestamps, or uniqueness. Run
buddy generate:migrations after changing pivot metadata.
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. Model fixture data needs no separate seeder or registration.
Idempotent application bootstrap work can live in database/seeders as a
default-exported class extending Seeder from @stacksjs/database. Buddy runs
those application seeders after the model factories.
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.
Built-in models by category
The ones below are worth knowing by name. They are a selection, not the set:
storage/framework/defaults/app/Models/ holds 103, and that directory is the
authority. This section said "All 62 built-in models by category" while
listing fewer than that against 102 on disk, so an agent reading to the end
had no way to tell it was short.
No count of what this section itself lists, deliberately - that number is
maintained by hand, drifts the moment anyone adds a bullet, and is the same
habit that produced the "All 62". The total above is pinned by
buddy docs:agent-counts.
Run find storage/framework/defaults/app/Models -name '*.ts' for the full
list, or buddy list for what a given project resolves.
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-43description: 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 103 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. Also confers `morphMany: { tokenable: 'PersonalAccessToken' }`, so any authenticatable model can hold API tokens |100| `useApi` | Generates REST actions and routes: `{ uri, routes, middleware? }` |101| `useSearch` (alias `searchable`) | Search-engine indexing: `{ displayable, searchable, sortable, filterable }` |102| `useSocials` | OAuth identities, e.g. `['github']` |103| `useActivityLog` | Writes an `activities` feed row per change: `{ logOnly }` / `{ include }` / `{ exclude }` pick the attributes |104| `useAudit` | Writes a `model_audits` row per change with an old/new diff |105| `observe` | Emits `{model}:created` / `:updated` / `:deleted` events |106| `billable` | Stripe methods (`checkout()`, `activeSubscription()`, ...) |107| `taggable` / `categorizable` / `commentable` / `likeable` | Pivot tables and their relation methods |108109Also at the top level: `indexes: [{ name, columns, unique?, where? }]` for110composite and partial-unique indexes, and `dashboard: { highlight: true }` to111feature the model in the admin UI.112113`useApi` is an API capability, not a dashboard-view generator. Its generated114routes are registered from the merged model registry. Framework defaults are115loaded first, then recursive `app/Models/` definitions override matching model116names. Protect non-public resources at the model:117118```ts119useApi: {120 uri: 'mail-preferences',121 routes: ['index', 'store', 'show', 'update', 'destroy'],122 middleware: ['auth'],123}124```125126Dashboard-specific endpoints may still use scoped Actions when their transport127shape, authorization boundary, or aggregation differs from generic CRUD. Do128not expose a sensitive model through unguarded generated routes just because a129separate dashboard endpoint is protected.130131Generated store and update routes accept both spellings of every fillable132attribute and each foreign key implied by `belongsTo`. Declaring Product as a133belongs-to relation therefore accepts `productId` or `product_id` without134duplicating that relationship column as an attribute.135136### Relationships137138`hasOne`, `hasMany`, `belongsTo`, `belongsToMany`, `hasOneThrough`,139`hasManyThrough`, `morphOne`, `morphMany`, `morphTo`, `morphToMany`,140`morphedByMany`. Each takes an array of model names, or an object form when you141need to name the foreign key.142143The object form is also where a `belongsTo` says what happens to its row when144the row it points at is deleted:145146```ts147belongsTo: [148 { model: 'Repository', onDelete: 'cascade' },149 { model: 'User', foreignKey: 'author_id', onDelete: 'set null' },150],151```152153`'cascade' | 'set null' | 'restrict' | 'no action'`, enforced by the database154on the foreign key. Left off, the default applies: the delete is refused while155a child still points at the row.156157Worth declaring rather than deleting children in application code. The order158has to be right in every place that deletes, forever, and the place that misses159one leaves rows nothing can reach - while the database applies the rule to160deletes the application never made: a manual `DELETE`, a restore, another161service sharing the schema. Not for a polymorphic pair (`commentable_id`162beside `commentable_type`): those carry no foreign key at all, because a163constraint would name one table and reject every row pointing at another.164165Use the named object form for a many-to-many relation that owns its pivot166schema. It keeps the relation accessor, migration, pivot defaults, timestamps,167and uniqueness in the model definition:168169```ts170belongsToMany: {171 tags: {172 model: 'Tag',173 table: 'taggable_models',174 foreignKey: 'taggable_id',175 relatedKey: 'tag_id',176 pivot: {177 columns: {178 taggable_type: { default: 'posts' },179 },180 timestamps: true,181 uniques: [['tag_id', 'taggable_id', 'taggable_type']],182 },183 },184},185```186187An instance then exposes the named relation directly:188189```ts190const post = await Post.find(id)191await post.tags().sync(tagIds)192await post.tags().detach()193```194195The legacy array form remains supported. Prefer the named form when the pivot196has custom keys, columns, defaults, timestamps, or uniqueness. Run197`buddy generate:migrations` after changing pivot metadata.198199### Computed properties and scopes200201```ts202get: {203 fullName: (model) => `${model.firstName} ${model.lastName}`,204},205set: {206 password: (value) => makeHash(value),207},208scopes: {209 published: (query) => query.where('status', 'published'),210},211```212213## Workflow214215```sh216buddy make:model Product # scaffold app/Models/Product.ts217buddy generate:migrations # diff models against the schema, emit SQL218# review the generated file in database/migrations/219buddy migrate # apply it220buddy migrate:fresh --seed # dev only: drop, re-migrate, seed221```222223Models resolve at runtime through `createModel()` from `bun-query-builder` -224there is no build step between editing a model and querying it. Only migrations225need generating.226227## Seeding228229Seed data is declared on the model, through the `useSeeder` trait plus the230per-attribute `factory` functions:231232```ts233traits: {234 useSeeder: {235 count: 20,236 // Optional: pin specific rows over the generated ones. Keys use the237 // model's camelCase attribute names.238 fixtures: [239 { name: 'Flagship Widget', status: 'published' },240 ],241 },242},243```244245`buddy seed` walks every model carrying the trait and fills its table from the246attribute factories. Model fixture data needs no separate seeder or registration.247Idempotent application bootstrap work can live in `database/seeders` as a248default-exported class extending `Seeder` from `@stacksjs/database`. Buddy runs249those application seeders after the model factories.250251```bash252buddy seed # every model with a useSeeder trait253buddy seed --fresh # truncate each table first254buddy seed --only Product,Review # just these models255buddy seed --except User # everything but these256buddy seed --include-defaults # framework built-ins too257```258259A model with no `useSeeder` trait is never seeded. Auth and OAuth models are260skipped on a non-fresh database so re-seeding cannot invalidate live sessions -261pass `--allow-protected` to override.262263## Built-in models by category264265The ones below are worth knowing by name. They are a selection, not the set:266`storage/framework/defaults/app/Models/` holds 103, and that directory is the267authority. This section said "All 62 built-in models by category" while268listing fewer than that against 102 on disk, so an agent reading to the end269had no way to tell it was short.270271No count of what this section itself lists, deliberately - that number is272maintained by hand, drifts the moment anyone adds a bullet, and is the same273habit that produced the "All 62". The total above is pinned by274`buddy docs:agent-counts`.275276Run `find storage/framework/defaults/app/Models -name '*.ts'` for the full277list, or `buddy list` for what a given project resolves.278279### Users & Auth280- **User** — name, email, password | traits: useAuth(passkey), useUuid, useTimestamps, useSocials(github) | hasOne: Subscriber, Driver, Author | hasMany: PersonalAccessToken, Customer281- **Author** — name, email | belongsTo: User | hasMany: Post282- **Customer** — name, email, phone, totalSpent, lastOrder, status, avatar | belongsTo: User | hasMany: Order, GiftCard, Review, Payment283- **Driver** — name, phone, vehicleNumber, license, status | belongsTo: User | hasMany: DeliveryRoute284- **Subscriber** — email, status, source | belongsTo: User | hasMany: SubscriberEmail285286### Content287- **Post** — title, content, poster, excerpt, views, publishedAt, status, isFeatured | belongsTo: Author | traits: categorizable, taggable, commentable | seeder: 20288- **Page** — similar to Post with taggable, categorizable289- **Comment** — author info, approval, content fields290- **Tag** — name(unique), slug(unique), description, postCount, color | seeder: 15291- **Category** — name, description, slug, imageUrl, isActive, parentCategoryId, displayOrder | hasMany: Product | seeder: 10292293### Commerce (20+ models)294- **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: highlighted295- **ProductVariant** — SKU, options, pricing296- **ProductUnit** — unit-specific pricing297- **Cart** — status(active|abandoned|converted|expired), totalItems, subtotal, taxAmount, discountAmount, total, expiresAt, currency(USD), notes | hasMany: CartItem | belongsTo: Customer, Coupon298- **CartItem** — quantity(min1), unitPrice, totalPrice, taxRate, taxAmount, discountPercentage, productName, productSku | belongsTo: Cart299- **Order** — status, totalAmount, taxAmount, discountAmount, deliveryFee, tipAmount, orderType(DINE_IN|TAKEOUT|DELIVERY), deliveryAddress, specialInstructions | hasMany: OrderItem, Payment | belongsTo: Customer, Coupon | observe: true | seeder: 20300- **OrderItem** — quantity(min1), price(min0), specialInstructions | belongsTo: Order, Product301- **Coupon** — code(unique), discountType(fixed_amount|percentage), discountValue, minOrderAmount, usageLimit, usageCount, startDate, endDate | seeder: 15302- **GiftCard** — code(unique), initialBalance, currentBalance, currency, status, recipientEmail, isDigital, isReloadable, expiryDate | seeder: 20303- **Manufacturer** — manufacturer info304- **Review** — rating(1-5), title, content(max2000), isVerifiedPurchase, isApproved, isFeatured, helpfulVotes, unhelpfulVotes | belongsTo: Product, Customer | seeder: 50305306### Shipping & Delivery307- **ShippingMethod**, **ShippingRate** (weightFrom, weightTo, rate), **ShippingZone**308- **DeliveryRoute** — driver, vehicle, stops, totalDistance | belongsTo: Driver309- **DigitalDelivery** — name, downloadLimit, expiryDays, automaticDelivery310- **LicenseKey** — key(XXXX-XXXX-XXXX-XXXX-XXXX), template, expiryDate, status311312### Payments & Financial313- **Payment** — amount, method(creditCard|debitCard|paypal|...), status(pending|completed|failed|refunded), currency, transactionId(unique) | belongsTo: Order, Customer | seeder: 50314- **PaymentMethod**, **PaymentProduct**, **PaymentTransaction**315- **Subscription** — type, providerId, providerStatus, unitPrice316- **Transaction** — standard transaction tracking317- **TaxRate** — name, rate(0-100), type(VAT|GST|Sales Tax|Customs Duty), country, region, isDefault318319### Engagement & Marketing320- **Notification** — type, channel, recipient, subject, body, status(pending|sent|delivered|failed|read) | belongsTo: User | seeder: 30321- **Campaign** — name, type(email|sms|push|social|multi-channel), status, audienceSize, openRate, clickRate, budget | seeder: 10322- **Activity** — type, description, subjectType, subjectId, causer, properties(JSON), ipAddress | belongsTo: User | seeder: 50323- **EmailList**, **SocialPost**, **LoyaltyPoint** (walletId, points, source, expiryDate), **LoyaltyReward**324325### System326- **Job** — queue, payload, attempts, available_at, reserved_at | seeder: 15327- **FailedJob** — failed background jobs328- **Error** — type, message, stack, status, additionalInfo | seeder: 10329- **Log** — application logs330- **Request** — method, path, statusCode, durationMs, ipAddress, memoryUsage, userAgent, errorMessage | seeder: 50331- **Websocket** — connection tracking332- **PrintDevice** — name, location, terminal, lastPing, printCount, isActive333- **WaitlistProduct**, **WaitlistRestaurant** — waitlist tracking334- **Receipt** — receipt records335336## CLI Commands337- `buddy make:model [name]` — scaffold a model in `app/Models/`338- `buddy publish:model [name]` — copy a built-in model into `app/Models/` to override it339- `buddy generate:migrations` — diff models against the schema and emit SQL340- `buddy migrate` / `buddy migrate:fresh --seed` — apply migrations341- `buddy make:migration [name]` — hand-write a migration instead342- `buddy make:factory [name]` — standalone factory343- `buddy seed` — seed every model carrying a `useSeeder` trait344345## Gotchas346- **No code generation step for models.** `defineModel()` calls `createModel()`347 from bun-query-builder at runtime, so a model is queryable the moment you save348 it. Only migrations are generated.349- **Migrations come from models.** Change the model, run `buddy generate:migrations`,350 review the SQL, then `buddy migrate`. Editing a generated migration by hand351 will be overwritten by the next diff.352- **`commentable`, not `commentables`.** `define-model` only checks the singular353 key. The plural spelling used to type check while leaving the trait inert.354- **Seeding is model-declared.** `useSeeder` sets the count and fixtures; the355 per-attribute `factory` functions produce the values. There are no seeder356 files to write or register.357- **`hidden` is serialization, `guarded` is mass assignment.** They are different358 protections; a password wants both `hidden` and no `fillable`.359- **`validation.rule` is mandatory** on every attribute - it drives both request360 validation and the inferred column type.361- Dashboard-highlighted models (`dashboard: { highlight: true }`) appear362 prominently in the admin UI.