Skip to main content
The AdonisJS Starter Kit uses Lucid ORM for database interactions. Models provide an object-oriented way to work with your database tables.

Model Organization

Models are organized by feature:
apps/web/app/
├── users/
│   ├── models/
│   │   ├── user.ts
│   │   ├── role.ts
│   │   └── reset_password_token.ts
│   ├── database/
│   │   ├── migrations/
│   │   ├── seeders/
│   │   └── factories/
│   └── controllers/
└── common/
    └── models/
        └── base_model.ts
All feature-specific models extend BaseModel from #common/models/base_model.

Base Model

The starter kit provides a base model with common functionality:
app/common/models/base_model.ts
import { BaseModel as LucidBaseModel } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
import { column } from '@adonisjs/lucid/orm'

export default class BaseModel extends LucidBaseModel {
  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime
}

Creating Models

Generate a new model:
node ace make:model Post
This creates a model file:
app/posts/models/post.ts
import { column } from '@adonisjs/lucid/orm'
import BaseModel from '#common/models/base_model'

export default class Post extends BaseModel {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare title: string

  @column()
  declare content: string
}

User Model

Here’s the actual User model from the starter kit:
app/users/models/user.ts
import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers'
import { belongsTo, column, computed, hasMany } from '@adonisjs/lucid/orm'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'

import { attachment, attachmentManager } from '@jrmc/adonis-attachment'
import type { Attachment } from '@jrmc/adonis-attachment/types/attachment'

import BaseModel from '#common/models/base_model'
import Role from '#users/models/role'
import Roles from '#users/enums/role'
import ResetPasswordToken from '#users/models/reset_password_token'

const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
  uids: ['email'],
  passwordColumnName: 'password',
})

export default class User extends compose(BaseModel, AuthFinder) {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare roleId: number

  @column()
  declare fullName: string | null

  @column()
  declare email: string

  @column({ serializeAs: null })
  declare password: string | null

  @attachment({ preComputeUrl: false, variants: ['thumbnail'] })
  declare avatar: Attachment

  @column()
  declare avatarUrl: string | null

  @belongsTo(() => Role)
  declare role: BelongsTo<typeof Role>

  @hasMany(() => ResetPasswordToken)
  declare resetPasswordTokens: HasMany<typeof ResetPasswordToken>

  @computed()
  get isAdmin() {
    return this.roleId === Roles.ADMIN
  }

  static async preComputeUrls(models: User | User[]) {
    if (Array.isArray(models)) {
      await Promise.all(models.map((model) => this.preComputeUrls(model)))
      return
    }

    if (!models.avatar) {
      return
    }

    const thumbnail = models.avatar.getVariant('thumbnail')

    if (thumbnail) {
      await attachmentManager.computeUrl(thumbnail)
    }
  }

  static accessTokens = DbAccessTokensProvider.forModel(User)
}
The withAuthFinder mixin adds authentication capabilities to the model, including password hashing and verification.

Role Model

Here’s the Role model with a one-to-many relationship:
app/users/models/role.ts
import { DateTime } from 'luxon'
import { column, hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'

import User from '#users/models/user'
import BaseModel from '#common/models/base_model'

export default class Role extends BaseModel {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare name: string

  @column()
  declare description: string

  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime

  @hasMany(() => User)
  declare users: HasMany<typeof User>
}

Columns

Column Decorators

@column()
declare title: string

Date Columns

import { DateTime } from 'luxon'

@column.dateTime({ autoCreate: true })
declare createdAt: DateTime

@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime

@column.date()
declare publishedOn: DateTime

Relationships

BelongsTo (Many-to-One)

A user belongs to a role:
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Role from './role'

export default class User extends BaseModel {
  @column()
  declare roleId: number

  @belongsTo(() => Role)
  declare role: BelongsTo<typeof Role>
}
Usage:
const user = await User.find(1)
await user.load('role')
console.log(user.role.name)

// Or with query
const user = await User.query()
  .where('id', 1)
  .preload('role')
  .firstOrFail()

HasMany (One-to-Many)

A role has many users:
import { hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import User from './user'

export default class Role extends BaseModel {
  @hasMany(() => User)
  declare users: HasMany<typeof User>
}
Usage:
const role = await Role.find(1)
await role.load('users')
console.log(role.users.length)

// Create related record
await role.related('users').create({
  email: '[email protected]',
  fullName: 'John Doe',
})

ManyToMany

import { manyToMany } from '@adonisjs/lucid/orm'
import type { ManyToMany } from '@adonisjs/lucid/types/relations'
import Tag from './tag'

export default class Post extends BaseModel {
  @manyToMany(() => Tag)
  declare tags: ManyToMany<typeof Tag>
}
Usage:
const post = await Post.find(1)

// Attach tags
await post.related('tags').attach([1, 2, 3])

// Detach tags
await post.related('tags').detach([2])

// Sync tags (detach all and attach new ones)
await post.related('tags').sync([1, 3, 4])

// Load tags
await post.load('tags')
console.log(post.tags)

HasOne

import { hasOne } from '@adonisjs/lucid/orm'
import type { HasOne } from '@adonisjs/lucid/types/relations'
import Profile from './profile'

export default class User extends BaseModel {
  @hasOne(() => Profile)
  declare profile: HasOne<typeof Profile>
}

Computed Properties

Computed properties are derived values:
export default class User extends BaseModel {
  @column()
  declare roleId: number

  @computed()
  get isAdmin() {
    return this.roleId === Roles.ADMIN
  }

  @computed()
  get initials() {
    return this.fullName
      ?.split(' ')
      .map(n => n[0])
      .join('')
      .toUpperCase() || ''
  }
}
Usage:
const user = await User.find(1)
console.log(user.isAdmin) // true/false
console.log(user.initials) // 'JD'

Querying

Basic Queries

const users = await User.all()

Query Builder

// Where clauses
const admins = await User.query()
  .where('role_id', Roles.ADMIN)
  .exec()

// Multiple conditions
const users = await User.query()
  .where('role_id', Roles.USER)
  .where('created_at', '>', DateTime.now().minus({ days: 7 }))
  .exec()

// Or conditions
const users = await User.query()
  .where('role_id', Roles.ADMIN)
  .orWhere('role_id', Roles.MODERATOR)
  .exec()

// Ordering
const users = await User.query()
  .orderBy('created_at', 'desc')
  .exec()

// Pagination
const users = await User.query()
  .paginate(page, perPage)

// Limiting
const users = await User.query()
  .limit(10)
  .exec()

Eager Loading

// Load single relationship
const users = await User.query()
  .preload('role')
  .exec()

// Load multiple relationships
const users = await User.query()
  .preload('role')
  .preload('resetPasswordTokens')
  .exec()

// Nested preloading
const posts = await Post.query()
  .preload('author', (query) => {
    query.preload('role')
  })
  .exec()

// Conditional preloading
const users = await User.query()
  .preload('role', (query) => {
    query.where('name', 'Admin')
  })
  .exec()

Creating Records

const user = await User.create({
  email: '[email protected]',
  fullName: 'John Doe',
  roleId: Roles.USER,
})

Updating Records

const user = await User.findOrFail(1)
user.fullName = 'Jane Doe'
await user.save()

Deleting Records

const user = await User.findOrFail(1)
await user.delete()

Hooks

Models support lifecycle hooks:
import { beforeSave, afterCreate } from '@adonisjs/lucid/orm'

export default class User extends BaseModel {
  @beforeSave()
  static async hashPassword(user: User) {
    if (user.$dirty.password) {
      user.password = await hash.make(user.password)
    }
  }

  @afterCreate()
  static async sendWelcomeEmail(user: User) {
    await sendEmail(user.email, 'Welcome!')
  }
}
Available hooks:
  • @beforeSave() - Before create or update
  • @afterSave() - After create or update
  • @beforeCreate() - Before create
  • @afterCreate() - After create
  • @beforeUpdate() - Before update
  • @afterUpdate() - After update
  • @beforeDelete() - Before delete
  • @afterDelete() - After delete
  • @beforeFind() - Before find query
  • @afterFind() - After find query
  • @beforeFetch() - Before fetch query
  • @afterFetch() - After fetch query

Serialization

Control how models are serialized to JSON:
const user = await User.findOrFail(1)

// Serialize to object
const data = user.serialize()

// Serialize to JSON
const json = user.toJSON()

// Hide fields
user.serialize({
  fields: {
    omit: ['password', 'createdAt']
  }
})

// Show only specific fields
user.serialize({
  fields: {
    pick: ['id', 'fullName', 'email']
  }
})

// Include relationships
user.serialize({
  relations: {
    role: {
      fields: ['id', 'name']
    }
  }
})

Best Practices

The query builder is more efficient and readable for complex queries:
// Good
const users = await User.query()
  .where('role_id', Roles.ADMIN)
  .preload('role')
  .orderBy('created_at', 'desc')
  .exec()

// Avoid
const users = await User.all()
const admins = users.filter(u => u.roleId === Roles.ADMIN)
Use eager loading to avoid N+1 queries:
// Good - 2 queries
const users = await User.query().preload('role').exec()

// Bad - N+1 queries
const users = await User.all()
for (const user of users) {
  await user.load('role')
}
Don’t add methods for simple computed values - use @computed() decorator.
Use serializeAs: null to prevent sensitive fields from being exposed:
@column({ serializeAs: null })
declare password: string

Next Steps

Database

Learn about migrations and seeders

Routes

Define routes for your models

Build docs developers (and LLMs) love