- name
- stacks-models
- description
- Use when defining or extending Stacks models, deriving migrations and CRUD, configuring attributes/relationships/traits/ownership, or preserving model inference. Covers defineModel, extendModel, model types, native data workflows, and the 107 built-in framework models.
- license
- MIT
- compatibility
- Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
- allowed-tools
- Read Edit Write Bash Grep Glob
# 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/` (grouped
into `commerce/`, `Content/`, `realtime/` and a flat top level)
- Actual `defineModel` contract: `storage/framework/core/orm/src/define-model.ts`
- Compatibility `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.
For an additive override, use `extendModel` rather than copying the full built-in
definition. Read [references/model-capabilities.md](references/model-capabilities.md)
for merge rules, casts, encryption, ownership, validation, lifecycle, search,
pagination, sharding, and the native type utilities.
## 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.
```ts
// app/Models/Product.ts
import { defineModel } from '@stacksjs/orm'
import { schema } from '@stacksjs/validation'
export default defineModel({
name: 'Product', // declare the model identity explicitly
table: 'products', // declare the database table explicitly
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
The modern `defineModel` contract accepts attributes inferred from an explicit
type, validation rule, factory, or default. A validator is recommended for
request-facing values, but is not mandatory for every internal attribute.
An unclassified value can infer `unknown`; declare the intended type instead
of forcing a cast. See `orm/tests/inference-attributes.test-d.ts`.
| 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 |
| `personal` | Personal data: exported by `gdpr:export`, anonymized by erasure and retention. `true`, or `{ anonymize?, export? }`. See "Personal data (GDPR)" below |
| `factory` | `(faker, attributes) => value`, used by seeders and tests. `attributes` holds what this record's earlier-declared factories produced, so one value can depend on another: `(faker, { discountType }) => ...` |
| `validation` | `{ rule, message? }` - declared rules are enforced on direct ORM writes and generated CRUD |
| `encrypted` | Encrypt persisted values and decrypt model reads; raw queries still see stored ciphertext |
### 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 |
| `gdpr` | Whose data the rows are (`subject`), what erasure does (`erasure`), how long rows live (`retention`), and why (`basis`, `purpose`). See "Personal data (GDPR)" below |
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:
```ts
useApi: {
uri: 'mail-preferences',
routes: ['index', 'store', 'show', 'update', 'destroy'],
middleware: ['auth'],
}
```
`security.api.models` and `STACKS_MODEL_APIS` select which `useApi` definitions
publish routes. The environment setting wins; `all`, `own`, `none`, or named
models can narrow the surface. An app override counts as its own model.
Selection does not add the trait or replace middleware/ownership checks, and
it is separate from feature bundle and database migration selection. See
`orm/tests/model-api-selection.test.ts` before configuring a restricted API.
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.
#### Who may reach the generated routes
Omit `middleware` and reads AND writes get `auth`: an undeclared read route is
how a customer list leaks (#2224). Four declaration forms, narrowest last:
```ts
middleware: ['auth'] // every ability
middleware: [] // every ability public; warns at boot
middleware: { read: [], write: ['auth'] } // public catalog, guarded writes
middleware: { write: ['auth'], destroy: ['auth', 'role:admin'] } // per ability
```
`read` covers `index` and `show`; `write` covers `store`, `update` and
`destroy`; an ability name overrides its side; and `destroy` covers bulk-delete
with the single delete. Use the per-ability form for the "fewer actions" half of
a tiered role - "may file an order, may not void one" is `store` versus
`destroy`, which the read/write split cannot express. Declaring one ability
leaves the other four on `auth`, so narrowing one route never widens another.
A model is also gated by the roles it declares for the dashboard:
```ts
dashboard: { roles: ['admin'] } // hides the sidebar row AND gates every generated route
```
That one declaration covers both surfaces (#2883). It appends `role:admin` to
the resolved middleware of each ability, unless that ability names a `role:`
itself (explicit wins) or was explicitly emptied (a deliberately public surface
is reported at boot, not quietly closed). `dashboard: { roles: [...], enforce:
false }` keeps the row hidden and the API as the middleware leaves it, for a row
hidden only to reduce clutter.
Row-level scoping is separate and already on: a model scopes rows by an
`ownership` config or a `team_id` column, and `security.api.rowScoping` defaults
to `'deny'`, which withholds store/update/destroy from a model that scopes
neither (#2375). A role says who may call the route; ownership says which rows
are theirs. A tiered app usually needs both.
#### Per-row rules: a policy
Middleware answers "may this caller use this endpoint". A policy answers "may
this caller do this to THIS row", which is the part a role cannot express. The
generated routes consult one when the model has one (#2883):
| Route | Ability | Subject |
|---|---|---|
| `index` | `viewAny` | none |
| `show` | `view` | the row |
| `store` | `create` | none |
| `update` | `update` | the existing row |
| `destroy`, bulk-delete | `delete` | the row, one call each |
```ts
// app/Policies/OrderPolicy.ts
export default class OrderPolicy {
viewAny(user) { return user !== null }
view(user, order) { return order.customer_id === user?.id }
delete(user, order) { return order.status === 'draft' && order.customer_id === user?.id }
}
```
Register it in `app/Gates.ts` under `policies`, or name it `<Model>Policy` under
`app/Policies/` and it is discovered by convention. A model with no policy is
unaffected, so adding one is opt-in per model. A refusal is a 403 carrying the
policy's own message; an ability the policy does not implement is a refusal too,
so a partial policy does not leave the rest open.
`Gate.inspectFor(model, ability, user, row)` is the same check from your own
code, keyed by model NAME rather than by instance - which is what a dashboard
action holding a query-builder row needs. It answers null for "no policy
registered", which is deliberately distinct from a denial; `Gate.allowsFor`
is the boolean form that reads null as allowed.
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:
```ts
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:
```ts
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:
```ts
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
```ts
get: {
fullName: (model) => `${model.firstName} ${model.lastName}`,
},
set: {
password: (attributes) => makeHash(attributes.password),
},
scopes: {
published: (query) => query.where('status', 'published'),
},
```
## Personal data (GDPR)
Declared on the model, read by access exports, erasure, retention and the
processing register (stacksjs/stacks#365). Full guide: `docs/guide/gdpr.md`.
```ts
belongsTo: ['Customer'],
traits: {
gdpr: {
subject: { via: 'Customer' }, // or 'user_id', ['a_id', 'b_id'], { column, where }, { email: 'col' }
erasure: 'anonymize', // default; or 'delete' | 'keep'
retention: { days: 3650, action: 'anonymize' }, // optional; column defaults to created_at
basis: 'legal_obligation',
purpose: 'Order fulfilment, kept for tax and accounting',
},
},
attributes: {
deliveryAddress: { personal: true, validation: { rule: schema.string() } },
},
```
- `subject` is derived when left out: `id` on `User`, the `belongsTo: ['User']`
foreign key elsewhere. A `via` parent must itself declare a subject.
- Anonymization writes NULL to nullable columns and a typed placeholder to
`NOT NULL` ones (`erased-<id>` when unique). A `NOT NULL` enum or timestamp
View on GitHub