Skip to main content

What is a Model?

Think of a model as a blueprint for your data. If you have a “users” table in your database, you create a “User” model to work with user data in your JavaScript code.
Real-world analogy: A model is like a form at the doctor’s office. The form has specific fields (name, age, symptoms), and each filled-out form represents one patient record. The model defines what fields exist, and each instance represents one record.

Creating Your First Model

# Create a model
npx ilana make:model User

# Create a model with migration
npx ilana make:model User --migration

# Create a model with everything (migration, factory, seeder)
npx ilana make:model User --all

Manual Creation

const Model = require('ilana-orm/orm/Model');

class User extends Model {
  // Table name (optional - defaults to lowercase plural)
  static table = 'users';
  
  // Enable timestamps (created_at, updated_at)
  static timestamps = true;
  
  // Fields that can be mass-assigned
  fillable = ['name', 'email', 'password'];
  
  // Fields to hide from JSON output
  hidden = ['password'];
}

module.exports = User;

Model Configuration

Table Settings

class User extends Model {
  // Specify table name (defaults to lowercase plural of class name)
  static table = 'users';
  
  // Primary key column (defaults to 'id')
  static primaryKey = 'id';
  
  // Primary key type ('number' or 'string')
  static keyType = 'number';
  
  // Whether primary key auto-increments (true for numbers, false for UUIDs)
  static incrementing = true;
  
  // Database connection to use (optional)
  static connection = 'mysql';
}

UUID Primary Keys

Set keyType = 'uuid' and incrementing = false — IlanaORM generates a UUID automatically on create:
class User extends Model {
  static table = 'users';
  static keyType = 'uuid';
  static incrementing = false;
}

const user = await User.create({ name: 'John Doe', email: 'john@example.com' });
console.log(user.id); // "550e8400-e29b-41d4-a716-446655440000"

ULID Primary Keys

ULIDs are 26-character sortable IDs — they’re URL-safe, lexicographically ordered, and include a millisecond timestamp prefix so they sort naturally by creation time:
class Order extends Model {
  static table = 'orders';
  static keyType = 'ulid';
  static incrementing = false;
}

const order = await Order.create({ total: 49.99 });
console.log(order.id); // "01J3X7KQZB8YTPNMCHW4RSVFGE"
Use char(26) for the column type in migrations:
export async function up(schema) {
  await schema.createTable('orders', (table) => {
    table.string('id', 26).primary();
    table.decimal('total', 10, 2);
    table.timestamps();
  });
}

Timestamps

Control automatic timestamp handling:
class User extends Model {
  // Enable/disable timestamps
  static timestamps = true;
  
  // Customize timestamp column names
  static createdAt = 'created_at';
  static updatedAt = 'updated_at';
}

Soft Deletes

Mark records as deleted without actually removing them:
class User extends Model {
  static softDeletes = true;
  static deletedAt = 'deleted_at'; // Column name for soft delete timestamp
}

// Usage
await user.delete(); // Sets deleted_at timestamp
const users = await User.all(); // Only returns non-deleted users
const allUsers = await User.withTrashed().all(); // Includes deleted users
const deletedUsers = await User.onlyTrashed().all(); // Only deleted users

Mass Assignment Protection

Control which fields can be set when creating or updating records:
class User extends Model {
  // Fields that CAN be mass-assigned
  fillable = ['name', 'email', 'password'];
  
  // OR fields that CANNOT be mass-assigned
  guarded = ['id', 'is_admin', 'created_at'];
  
  // OR disable all protection (not recommended)
  // static unguarded = true;
}

// This works
const user = await User.create({
  name: 'John',
  email: 'john@example.com',
  password: 'secret',
});

// This would be ignored (not in fillable)
const user = await User.create({
  name: 'John',
  is_admin: true, // Ignored!
});
Security Note: Always use fillable or guarded to prevent users from setting sensitive fields like is_admin or balance.

Attribute Casting

Automatically convert database values to JavaScript types:
class User extends Model {
  casts = {
    // Convert to Date objects
    email_verified_at: 'date',
    birth_date: 'date',
    
    // Convert to booleans
    is_admin: 'boolean',
    is_active: 'boolean',
    
    // Convert to numbers
    age: 'number',
    salary: 'float',
    
    // Parse JSON
    preferences: 'json',
    metadata: 'object',
    tags: 'array',
  };
}

// Usage
const user = await User.find(1);
console.log(user.is_admin); // true (boolean, not "1" or 1)
console.log(user.preferences); // { theme: 'dark' } (object, not JSON string)
console.log(user.email_verified_at); // Date object

Available Cast Types

Cast TypeDescriptionExample
'boolean'Converts to true/false"1"true
'number'Converts to integer"42"42
'float'Converts to decimal"3.14"3.14
'string'Converts to string42"42"
'date'Passes value through as-is (no JS Date conversion — use DateCast class for that)"2023-12-01""2023-12-01"
'json'Parses JSON string'{"a":1}'{a:1}
'object'Same as json'{"a":1}'{a:1}
'array'Parses JSON array'[1,2,3]'[1,2,3]

Cast Class Instances

You can also pass cast class instances (objects with get(value) / set(value) methods). They are called automatically the same way string casts are:
const { MoneyCast, JsonCast, DateCast } = require('ilana-orm/orm/CustomCasts');

class Product extends Model {
  casts = {
    price:        new MoneyCast(),   // stores cents, returns dollars
    metadata:     new JsonCast(),
    released_at:  new DateCast(),    // returns JS Date object
  };
}

// get() is called automatically on read, set() on write
const product = await Product.find(1);
console.log(product.price); // 19.99 (MoneyCast.get() called automatically)
product.price = 29.99;      // MoneyCast.set() called automatically, stored as 2999
See the Custom Casting guide to build your own cast classes.

Default Values

Set default values for new model instances:
class User extends Model {
  attributes = {
    is_active: true,
    role: 'user',
    preferences: { theme: 'light' },
  };
}

// Usage
const user = new User();
console.log(user.is_active); // true
console.log(user.role); // 'user'

Hidden Attributes

Hide sensitive fields from JSON output:
class User extends Model {
  hidden = ['password', 'remember_token', 'api_key'];
}

// Usage
const user = await User.find(1);
console.log(user.toJSON()); // password field won't be included

// Temporarily show hidden fields
console.log(user.makeVisible(['password']).toJSON());

Mutators and Accessors

Transform data when setting or getting attributes:

Mutators (Setters)

Mutators are called automatically whenever an attribute is set — on direct assignment (user.email = x), via fill(), and via update(). They must return the transformed value.
class User extends Model {
  // Called automatically any time password is assigned
  setPasswordAttribute(value) {
    return value ? bcrypt.hashSync(value, 10) : value;
  }
  
  // Called automatically any time email is assigned
  setEmailAttribute(value) {
    return value ? value.toLowerCase().trim() : value;
  }
  
  setNameAttribute(value) {
    return value ? value.replace(/\w\S*/g, (txt) => 
      txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
    ) : value;
  }
}

// Mutators fire automatically — no special call needed
const user = await User.create({
  name: 'john doe',               // Stored as "John Doe"
  email: ' JOHN@EXAMPLE.COM ',   // Stored as "john@example.com"
  password: 'secret123',          // Stored hashed
});

user.email = 'NEW@EXAMPLE.COM';   // Mutator fires on assignment too
console.log(user.email);          // "new@example.com"

Accessors (Getters)

Accessors are called automatically on direct property access. List the key in appends to include it in toJSON() output.
class User extends Model {
  // Keys in appends are accessible as direct properties and included in toJSON()
  appends = ['full_name', 'avatar_url', 'formatted_created_at'];

  getFullNameAttribute() {
    return `${this.first_name} ${this.last_name}`;
  }
  
  getAvatarUrlAttribute() {
    return this.avatar 
      ? `/uploads/avatars/${this.avatar}`
      : '/images/default-avatar.png';
  }
  
  getFormattedCreatedAtAttribute() {
    return this.created_at.toLocaleDateString();
  }
}

const user = await User.find(1);
console.log(user.full_name);             // "John Doe" — accessor called directly
console.log(user.avatar_url);            // "/uploads/avatars/john.jpg"
console.log(user.formatted_created_at);  // "12/1/2023"

// Also included in toJSON() because they're in appends
const userData = user.toJSON();
console.log(userData.full_name);         // "John Doe"

// Dynamically add an accessor key to appends at runtime
user.append('full_name');

Model Events

Hook into the model lifecycle:
class User extends Model {
  static {
    // Before saving (create or update)
    this.saving(async (user) => {
      user.email = user.email.toLowerCase();
    });
    
    // After saving
    this.saved(async (user) => {
      await clearUserCache(user.id);
    });
    
    // Before creating
    this.creating(async (user) => {
      user.uuid = generateUuid();
    });
    
    // After creating
    this.created(async (user) => {
      await sendWelcomeEmail(user.email);
    });
    
    // Before updating
    this.updating(async (user) => {
      if (user.isDirty('email')) {
        user.email_verified_at = null;
      }
    });
    
    // After updating
    this.updated(async (user) => {
      await syncUserData(user);
    });
    
    // Before deleting
    this.deleting(async (user) => {
      await user.posts().delete();
    });
    
    // After deleting
    this.deleted(async (user) => {
      await cleanupUserFiles(user.id);
    });
  }
}
Available Events:
  • saving / saved - Before/after create or update
  • creating / created - Before/after create
  • updating / updated - Before/after update
  • deleting / deleted - Before/after delete
  • restoring / restored - Before/after soft delete restore

Muting Events

Sometimes you need to save records without triggering events — for example in seeders, migrations, or bulk imports. Use withoutEvents():
await User.withoutEvents(async () => {
  await User.create({ name: 'Admin', email: 'admin@example.com' });
  await User.create({ name: 'Bot', email: 'bot@example.com' });
  // creating/created/saving/saved events are all suppressed
});

// Events fire normally again after this point

Query Scopes

Create reusable query constraints:
class Post extends Model {
  // Simple scope
  static scopePublished(query) {
    return query.where('is_published', true);
  }
  
  // Scope with parameters
  static scopeOfType(query, type) {
    return query.where('type', type);
  }
  
  // Complex scope
  static scopePopular(query, threshold = 100) {
    return query
      .where('views', '>', threshold)
      .orderBy('views', 'desc');
  }
  
  // Date-based scope
  static scopeRecent(query, days = 7) {
    const date = new Date();
    date.setDate(date.getDate() - days);
    return query.where('created_at', '>', date);
  }
}

// Usage
const posts = await Post.query()
  .published()           // Uses scopePublished
  .ofType('article')     // Uses scopeOfType
  .popular(500)          // Uses scopePopular with parameter
  .recent(30)            // Uses scopeRecent with parameter
  .get();

Working with Model Instances

Creating Records

// Method 1: create() - saves immediately
const user = await User.create({
  name: 'John Doe',
  email: 'john@example.com',
});

// Method 2: new + save()
const user = new User({
  name: 'John Doe',
  email: 'john@example.com',
});
await user.save();

// Method 3: make() - doesn't save
const user = User.make({
  name: 'John Doe',
  email: 'john@example.com',
});
// ... do something with user
await user.save();

Reading Records

// Find by primary key
const user = await User.find(1);
const user = await User.findOrFail(1); // Throws error if not found

// Find by other attributes
const user = await User.findBy('email', 'john@example.com');

// Get first record
const user = await User.first();
const user = await User.firstOrFail();

// Get all records
const users = await User.all();

// Find or create
const user = await User.firstOrCreate(
  { email: 'john@example.com' },  // Search criteria
  { name: 'John Doe' }            // Additional data if creating
);

// Update or create
const user = await User.updateOrCreate(
  { email: 'john@example.com' },  // Search criteria
  { name: 'John Smith', is_active: true } // Data to update/create
);

Updating Records

// Update single record
const user = await User.find(1);
user.name = 'John Smith';
await user.save();

// Or use update method
await user.update({ name: 'John Smith' });

// Update multiple records
await User.query()
  .where('is_active', false)
  .update({ is_active: true });

// Increment/decrement — no fetch needed, updates DB and local attribute
await user.increment('login_count');       // login_count + 1
await user.increment('points', 10);        // points + 10
await user.decrement('credits', 5);        // credits - 5

// Bulk increment/decrement via query builder
await User.query().where('role', 'admin').increment('credits', 100);

Deleting Records

// Delete single record
const user = await User.find(1);
await user.delete();

// Delete multiple records
await User.query()
  .where('is_active', false)
  .delete();

// Soft delete (if enabled)
await user.delete(); // Sets deleted_at
await user.restore(); // Removes deleted_at

// Bulk restore — restore many at once
await User.query().onlyTrashed().where('role', 'admin').restore();

// Force delete (permanent)
await user.forceDelete();

Pruning Models

Pruning lets you automatically delete stale records (e.g. old logs, expired tokens) by defining what “prunable” means on the model:
class ActivityLog extends Model {
  static table = 'activity_logs';

  // Return a query for records that should be deleted
  static prunable() {
    return this.query().where('created_at', '<', new Date(Date.now() - 90 * 86400_000));
    // deletes logs older than 90 days
  }
}

// Delete all prunable records — processes in chunks of 1000 to avoid memory issues
const deleted = await ActivityLog.prune();
console.log(`Pruned ${deleted} records`);
Run prune() on a schedule (e.g. a daily cron job) to keep your tables clean.

Model Utilities

Reload and Compare Instances

// Re-fetch a fresh copy from the database
const user = await User.find(1);
const freshUser = await user.fresh(); // new instance with up-to-date attributes

// Check if two variables refer to the same database record
const a = await User.find(1);
const b = await User.find(1);
console.log(a.is(b));    // true  — same class + same primary key
console.log(a.isNot(b)); // false

const c = await User.find(2);
console.log(a.is(c));    // false
console.log(a.isNot(c)); // true

Replicating Models

replicate() creates an unsaved copy of a model, excluding the primary key and timestamps so it can be inserted as a new row:
const original = await Post.find(1);
const copy = original.replicate();

// copy has same body/title/status — but no id or timestamps
copy.title = 'Copy of ' + original.title;
await copy.save(); // inserts as a new record
You can exclude additional columns from the copy:
const copy = original.replicate(['slug', 'published_at']);

Table Helpers

// Delete every row in the table (useful in tests / seeders)
await User.truncate();

// Create records using the model's registered factory
await User.seed(10); // insert 10 users via factory
seed() requires a factory to be defined for the model with defineFactory. See Factories for setup details.

Enum Helpers

Define the possible values for an enum column once, and IlanaORM generates isX() / makeX() helpers automatically on every instance:
class User extends Model {
  static enums = {
    role: ['user', 'moderator', 'admin'],
    status: ['active', 'suspended', 'banned'],
  };
}

const user = await User.find(1);

// Check current value
user.isAdmin();      // true / false
user.isModerator();  // true / false
user.isActive();     // true / false

// Change value and save
await user.makeAdmin();     // sets role = 'admin' and saves
await user.makeSuspended(); // sets status = 'suspended' and saves

Check Model State

const user = await User.find(1);

// Check if model exists in database
console.log(user.exists); // true

// Check if model has been modified
user.name = 'New Name';
console.log(user.isDirty()); // true
console.log(user.isDirty('name')); // true
console.log(user.isDirty('email')); // false

// Get changed attributes
console.log(user.getDirty()); // { name: 'New Name' }

// Get original values
console.log(user.getOriginal()); // Original data from database
console.log(user.getOriginal('name')); // Original name value

Convert to Different Formats

const user = await User.find(1);

// Convert to plain object (includes appended accessors)
const userData = user.toJSON();

// Convert to JSON string
const jsonString = JSON.stringify(user);

// Get only specific attributes
const publicData = user.only(['id', 'name', 'email']);
const privateData = user.except(['password']);

// Temporarily append accessors
const userWithAccessors = user.append(['full_name', 'avatar_url']).toJSON();

Best Practices

1. Use Descriptive Names

// ✅ Good
class BlogPost extends Model {
  static table = 'blog_posts';
}

// ❌ Avoid
class BP extends Model {
  static table = 'bp';
}

2. Always Use Mass Assignment Protection

// ✅ Good
class User extends Model {
  fillable = ['name', 'email', 'password'];
}

// ❌ Dangerous
class User extends Model {
  static unguarded = true; // Anyone can set any field!
}

3. Hide Sensitive Data

class User extends Model {
  hidden = ['password', 'remember_token', 'api_key'];
}

4. Use Appropriate Casting

class User extends Model {
  casts = {
    email_verified_at: 'date',
    is_admin: 'boolean',
    preferences: 'json',
  };
}
class User extends Model {
  // Configuration at the top
  static table = 'users';
  static timestamps = true;
  
  // Mass assignment
  fillable = ['name', 'email'];
  hidden = ['password'];
  
  // Casting
  casts = {
    email_verified_at: 'date',
    is_admin: 'boolean',
  };
  
  // Relationships
  posts() {
    return this.hasMany('Post');
  }
  
  // Scopes
  static scopeActive(query) {
    return query.where('is_active', true);
  }
  
  // Mutators/Accessors
  setEmailAttribute(value) {
    return value.toLowerCase();
  }
}

Next Steps

Learn Queries

Discover how to find and filter your data

Set Up Relationships

Connect different models together