attributes: { name: { factory: faker => faker.person.fullName(), },

email: { factory: faker => faker.internet.email(), },

status: { factory: faker => faker.helpers.arrayElement(['active', 'inactive', 'pending']), },

// Factory with access to other attributes slug: { factory: (faker, attributes) => { const name = attributes?.name || faker.lorem.word() return name.toLowerCase().replace(/\s+/g, '-') }, }, }


## Relationships

### Has One

Define a one-to-one relationship:

```typescript

// app/Models/User.ts
export default {
  name: 'User',
  table: 'users',

  hasOne: ['Profile'],

  // Or with configuration
  hasOne: [
    {
      model: 'Profile',
      foreignKey: 'user_id',
      relationName: 'profile',
    },
  ],
} satisfies Model

Has Many

Define a one-to-many relationship:


// app/Models/User.ts
export default {
  name: 'User',
  table: 'users',

  hasMany: ['Post', 'Comment'],

  // Or with configuration
  hasMany: [
    {
      model: 'Post',
      foreignKey: 'author_id',
      relationName: 'posts',
    },
    {
      model: 'Comment',
      foreignKey: 'user_id',
    },
  ],
} satisfies Model

Belongs To

Define an inverse one-to-many relationship:


// app/Models/Post.ts
export default {
  name: 'Post',
  table: 'posts',

  belongsTo: ['User'],

  // Or with configuration
  belongsTo: [
    {
      model: 'User',
      foreignKey: 'author_id',
      relationName: 'author',
    },
  ],
} satisfies Model

Belongs To Many

Define a many-to-many relationship:


// app/Models/Post.ts
export default {
  name: 'Post',
  table: 'posts',

  belongsToMany: ['Tag'],

  // With pivot table configuration
  belongsToMany: [
    {
      model: 'Tag',
      pivotTable: 'post_tags',
      firstForeignKey: 'post_id',
      secondForeignKey: 'tag_id',
    },
  ],
} satisfies Model

Has One Through

Define a has-one-through relationship:


// app/Models/Country.ts
export default {
  name: 'Country',
  table: 'countries',

  hasOneThrough: [
    {
      model: 'Capital',
      through: 'State',
      throughForeignKey: 'country_id',
      relationName: 'capital',
    },
  ],
} satisfies Model

Polymorphic Relationships (Morph)

Define polymorphic relationships:


// app/Models/Comment.ts
export default {
  name: 'Comment',
  table: 'comments',

  morphOne: {
    model: 'Image',
    morphName: 'imageable',
    type: 'imageable_type',
    id: 'imageable_id',
  },
} satisfies Model

Traits

Available Traits


traits: {
  // Timestamps (created_at, updated_at)
  useTimestamps: true,

  // UUID column
  useUuid: true,

  // Soft deletes (deleted_at)
  useSoftDeletes: true,

  // Observable (emit events on changes)
  observe: true,

  // Searchable (Algolia/MeiliSearch integration)
  searchable: true,

  // Billable (Stripe integration)
  billable: true,

  // Auto-generate API routes
  useApi: {
    uri: 'posts',
    routes: ['index', 'store', 'show', 'update', 'destroy'],
    middleware: ['auth'],
  },

  // Seeder configuration
  useSeeder: {
    count: 50,
  },
}

API Trait Configuration

Generate RESTful API routes automatically:


traits: {
  useApi: {
    // Base URI for routes
    uri: 'posts',

    // Routes to generate
    routes: ['index', 'store', 'show', 'update', 'destroy'],

    // Or with custom action paths
    routes: {
      index: 'PostIndexAction',
      store: 'PostStoreAction',
      show: 'PostShowAction',
      update: 'PostUpdateAction',
      destroy: 'PostDestroyAction',
    },

    // Middleware for all routes
    middleware: ['auth', 'throttle:60,1'],
  },
}

This generates:

  • GET /posts - List posts
  • POST /posts - Create post
  • GET /posts/{id} - Show post
  • PATCH /posts/{id} - Update post
  • DELETE /posts/{id} - Delete post

Querying Models

Basic Queries


import { Post } from '@stacksjs/orm'

// Find by ID
const post = await Post.find(1)

// Find or fail
const post = await Post.findOrFail(1)

// Get all records
const posts = await Post.all()

// First record
const post = await Post.first()

// Last record
const post = await Post.last()

Where Clauses


// Simple where
const posts = await Post.where('status', 'published').get()

// With operator
const posts = await Post.where('views', '>', 100).get()

// Multiple conditions
const posts = await Post
  .where('status', 'published')
  .where('author_id', 1)
  .get()

// Or where
const posts = await Post
  .where('status', 'published')
  .orWhere([['status', 'draft'], ['author_id', 1]])
  .get()

// Where in
const posts = await Post.whereIn('id', [1, 2, 3]).get()

// Where not in
const posts = await Post.whereNotIn('status', ['archived', 'deleted']).get()

// Where null
const posts = await Post.whereNull('deleted_at').get()

// Where not null
const posts = await Post.whereNotNull('published_at').get()

// Where between
const posts = await Post.whereBetween('created_at', ['2024-01-01', '2024-12-31']).get()

// Where like
const posts = await Post.whereLike('title', '%TypeScript%').get()

Ordering and Limiting


// Order by
const posts = await Post.orderBy('created_at', 'desc').get()

// Order by shortcuts
const posts = await Post.orderByDesc('created_at').get()
const posts = await Post.orderByAsc('title').get()

// Latest/Oldest
const latestPost = await Post.latest('created_at')
const oldestPost = await Post.oldest('created_at')

// Random order
const posts = await Post.inRandomOrder().get()

// Limit and offset
const posts = await Post.take(10).skip(20).get()

Selecting Columns


// Select specific columns
const posts = await Post.select(['id', 'title', 'created_at']).get()

// Distinct values
const statuses = await Post.distinct('status').get()

Aggregates


// Count
const total = await Post.count()

// Max
const maxViews = await Post.max('views')

// Min
const minViews = await Post.min('views')

// Average
const avgViews = await Post.avg('views')

// Sum
const totalViews = await Post.sum('views')

Pagination


const result = await Post.paginate({
  page: 1,
  limit: 15,
})

// Result shape:
// {
//   data: Post[],
//   paging: {
//     total_records: number,
//     page: number,
//     total_pages: number,
//   },
//   next_cursor: number | null,
// }

Eager Loading


// Load relationships
const posts = await Post.with(['author', 'comments']).get()

// Nested relationships
const posts = await Post.with(['author', 'comments.user']).get()

Chunking

Process large datasets efficiently:


await Post.chunk(100, async (posts) => {
  for (const post of posts) {
    await processPost(post)
  }
})

Creating and Updating

Creating Records


// Create single record
const post = await Post.create({
  title: 'Hello World',
  content: 'This is my first post',
  status: 'draft',
})

// Create multiple records
await Post.createMany([
  { title: 'Post 1', content: 'Content 1' },
  { title: 'Post 2', content: 'Content 2' },
])

// First or create
const post = await Post.firstOrCreate(
  { email: 'user@example.com' },  // Search criteria
  { name: 'New User' },            // Values to create with
)

// Update or create
const post = await Post.updateOrCreate(
  { email: 'user@example.com' },  // Search criteria
  { name: 'Updated Name' },        // Values to update/create with
)

Updating Records


// Update single record
const post = await Post.find(1)
await post.update({ title: 'Updated Title' })

// Save changes
post.title = 'New Title'
await post.save()

// Force update (bypass guarded)
await post.forceUpdate({ admin_only_field: 'value' })

Deleting Records


// Delete single record
const post = await Post.find(1)
await post.delete()

// Delete by ID
await Post.remove(1)

Scopes

Local Scopes

Define reusable query constraints:


// In your model usage
const publishedPosts = await Post
  .where('status', 'published')
  .where('published_at', '<=', new Date())
  .get()

// Create a helper function
async function getPublishedPosts() {
  return Post
    .where('status', 'published')
    .whereNotNull('published_at')
    .orderByDesc('published_at')
    .get()
}

Conditional Queries


const posts = await Post
  .when(request.has('category'), (query) => {
    return query.where('category_id', request.get('category'))
  })
  .when(request.has('search'), (query) => {
    return query.whereLike('title', `%${request.get('search')}%`)
  })
  .get()

Events

Model Observers

Enable model events with the observe trait:


traits: {
  observe: true,
}

This emits events on model changes:

  • {model}:created - After creation
  • {model}:updated - After update
  • {model}:deleted - After deletion

Listening to Events


import { events } from '@stacksjs/events'

events.on('post:created', async (post) => {
  console.log('New post created:', post.title)
  await notifySubscribers(post)
})

events.on('post:updated', async (post) => {
  await invalidateCache(`post:${post.id}`)
})

events.on('post:deleted', async (post) => {
  await cleanupRelatedData(post.id)
})

Soft Deletes

Enabling Soft Deletes


traits: {
  useSoftDeletes: true,
}

This adds a deleted_at column and modifies query behavior.

Querying with Soft Deletes


// Normal queries exclude soft-deleted records
const posts = await Post.all()

// Include soft-deleted records
const allPosts = await Post.withTrashed().get()

// Only soft-deleted records
const trashedPosts = await Post.onlyTrashed().get()

// Restore soft-deleted record
const post = await Post.withTrashed().find(1)
await post.restore()

// Permanently delete
await post.forceDelete()

JSON Serialization

Hiding Attributes


attributes: {
  password: {
    hidden: true,  // Never included in JSON
    fillable: true,
    validation: {
      rule: schema.string().min(8),
    },
  },
}

Converting to JSON


const post = await Post.find(1)

// Get JSON representation
const json = post.toJSON()

// Includes all visible attributes and loaded relationships

Edge Cases and Gotchas

Mass Assignment Protection

Attributes must be marked as fillable for mass assignment:


// This works
const post = await Post.create({
  title: 'Hello',      // fillable: true
  content: 'World',    // fillable: true
})

// This field is ignored
const post = await Post.create({
  admin_only: true,    // fillable: false (ignored)
})

// Use forceCreate to bypass
const post = await Post.forceCreate({
  admin_only: true,    // Works with forceCreate
})

Relationship Loading

Always use .with() for eager loading to avoid N+1 queries:


// Bad: N+1 queries
const posts = await Post.all()
for (const post of posts) {
  console.log(post.author.name)  // Query per post!
}

// Good: Eager loading
const posts = await Post.with(['author']).get()
for (const post of posts) {
  console.log(post.author.name)  // Already loaded
}

Transaction Support


import { transaction } from '@stacksjs/database'

await transaction(async (trx) => {
  const user = await User.create({ name: 'John' }, { transaction: trx })
  await Post.create({ title: 'Hello', author_id: user.id }, { transaction: trx })
  // Both operations commit or rollback together
})

API Reference

Model Instance Methods

MethodDescription
save()Save changes to database
update(data)Update with new data
forceUpdate(data)Update bypassing guarded
delete()Delete the record
toJSON()Convert to JSON object
toSearchableObject()Convert for search indexing

Static Query Methods

MethodDescription
find(id)Find by primary key
findOrFail(id)Find or throw error
findMany(ids)Find multiple by IDs
all()Get all records
first()Get first record
last()Get last record
create(data)Create new record
createMany(data[])Create multiple records
where(column, value)Add where clause
with(relations)Eager load relationships
paginate(options)Paginate results