On this page
models
introduction
dframework includes an active record implementation for interacting with your database. each database table has a corresponding model that is used to interact with that table. models allow you to query for data in your tables, as well as insert new records into the table, using an elegant and fluent interface.
defining models
to create a model, simply extend the base Model class provided by the framework.
1import { Model } from 'dframework';2 3export default class User extends Model {4 // 5}
table names
by default, the framework will use the lowercased, plural name of the class as the table name. for example, the User model will assume a users table exists. if your table does not follow this convention, you may specify a custom table name by overriding the static table property.
1export default class User extends Model {2 static table = 'system_users';3}
primary keys
the framework will automatically determine your table's primary key by inspecting the database schema and caching the result. if you want to override this behavior, you can define a static primaryKey property.
1export default class User extends Model {2 static primaryKey = 'uuid';3 static keyType = 'uuid';4}when static keyType = 'uuid' is configured, the model automatically handles uuid primary key generation and resolution during create() and save().
to retrieve the primary key value of any model instance regardless of the key column name, call model.getKey().
composite primary keys are automatically supported if they are defined in the schema.
attribute casting
attribute casting provides automatic conversion between database columns and javascript data types. define a static casts object on your model to configure casting for specific columns.
1export default class User extends Model {2 static casts = {3 metadata: 'json',4 tags: 'array',5 options: 'object',6 is_active: 'boolean',7 login_count: 'int',8 score: 'float',9 joined_at: 'datetime',10 display_name: 'string'11 };12}supported cast types
| cast type | description |
|---|---|
json, array, object |
parses stringified json into javascript objects or arrays on hydration and stringifies objects or arrays during save() and create(). |
boolean, bool |
casts values to boolean (true or false), handling '1', '0', 'true', 'false', 1, 0. |
int, integer |
casts values to integer using parseInt(). |
float, double, real |
casts values to floating point numbers using parseFloat(). |
date, datetime, timestamp |
casts values to Date objects on hydration and formats to standard sql timestamp format in utc on save. all database timestamps are standardized to utc across postgresql, mysql, and sqlite. |
string |
casts values to primitive strings. |
mutating json attributes in place
when attributes are cast to json, array, or object, you can mutate the object directly in memory and persist the changes with save().
1const user = await User.find(1);2 3// mutate nested properties directly4user.metadata.theme = 'dark';5user.metadata.notifications = true;6 7// save persists the stringified json payload to the database8await user.save();subclasses automatically inherit and merge static casts from parent model classes up the prototype chain.
automatic json casting
models automatically detect and serialize json data without requiring explicit casts. any attribute containing a plain javascript object or array is serialized to a json string during save(), create(), and query updates. similarly, database strings formatted as json (starting and ending with {} or []) are automatically parsed into javascript objects on hydration. to preserve raw json formatted strings as strings without parsing, declare an explicit 'string' cast.
inspecting casts
to inspect the resolved cast definitions for a model, call getCasts() on the model class or instance. this method walks the prototype inheritance chain and merges all parent casts:
1const casts = User.getCasts();2// casts: { metadata: 'json', tags: 'array', is_active: 'boolean', ... }
retrieving models
models proxy all methods from the fluent query builder, allowing you to chain constraints before fetching the results. the all method will retrieve all of the records from the model's table.
1const users = await User.all();2// users: Array<User>. hydrated model instances. [] when the table is empty.you may use the find method to retrieve a specific record by its primary key.
1const user = await User.find(1);2// user: User | null. null when no row matches the given id.
methods overview
every method that runs a query returns a Promise. the result column lists what that promise resolves with, including null and empty array semantics.
| method | arguments | returns |
|---|---|---|
Model.all() |
none | Promise<Model[]> (hydrated instances, [] when no rows) |
Model.query() |
none | ModelQueryBuilder (fresh query builder instance) |
Model.find(id) |
primary key value, or { pk1, pk2 } for composite keys |
Promise<Model|null> |
Model.first(where?) |
optional { column: value } object |
Promise<Model|null> |
Model.firstWhere(col, op?, val?) |
column name, operator/value, optional value | Promise<Model|null> |
Model.latest(count?, column?) |
null or number; optional column name |
null count: Promise<Model|null>; numeric count: Promise<Model[]> |
Model.where(...) |
column/operator/value, or object | ModelQueryBuilder (chainable, thenable, async iterable) |
Model.orderBy(...) / groupBy(...) / limit(...) / offset(...) / distinct(...) |
chain values | ModelQueryBuilder (chainable) |
Model.with(...relations) |
dot notation relation names | ModelQueryBuilder (chainable) |
Model.paginate(perPage, pageName?) |
rows per page (default 10), optional page param name | Promise<Paginator> |
Model.firstOrCreate(attributes, values?) |
attributes object; optional extra values | Promise<Model> (existing or newly created) |
Model.updateOrCreate(attributes, values?) |
attributes object; optional extra values | Promise<Model> (updated or newly created) |
Model.create(data) |
column/value object | Promise<Model> (reloaded from the database using the primary key) |
Model.isFillable(key) |
attribute name string | boolean (true if attribute is mass assignable) |
Model.filterAttributes(data) |
column/value object | object (shallow copy containing only fillable attributes) |
Model.getCasts() |
none | object (merged cast definitions resolved across inheritance chain) |
builder.clone() |
none | ModelQueryBuilder (isolated copy preserving relations and constraints) |
instance.save(newData?) |
optional object to merge before saving | Promise<boolean> (true on success, false if aborted by hook) |
instance.update(data) |
column/value object | Promise<boolean> (mutates in place and executes UPDATE; false if aborted) |
instance.touch() |
none | Promise<boolean> (updates updated_at timestamp in database) |
instance.increment(column, amount?, extra?) |
column name, optional amount (default 1), optional extra columns object | Promise<object> (mutates instance attribute in place and executes increment UPDATE) |
instance.decrement(column, amount?, extra?) |
column name, optional amount (default 1), optional extra columns object | Promise<object> (mutates instance attribute in place and executes decrement UPDATE) |
instance.delete() |
none | Promise<boolean> (deletes row by primary key; false if aborted by hook) |
instance.hash(field) |
attribute name on the instance | Promise<void> (mutates the instance in place) |
instance.load(...relations) |
relation names | Promise<Model> (the same instance, with relations populated) |
instance.toJSON() |
none | plain object with hidden keys removed and relations serialized |
Model.getHidden() |
none | array of all hidden attributes inherited across the prototype chain |
Model.getVisible() |
none | array of all visible attributes defined on the model or null |
the where chainable builder exposes the same constraint, closure grouping, conditional (when, unless), subquery (whereExists, whereIn, selectSub), single entity lookup (find, first), and aggregate methods as the fluent query builder (where, whereIn, whereNull, whereBetween, whereColumn, whereHashed, whereExists, when, unless, selectSub, join, leftJoin, rightJoin, crossJoin, joinRaw, count, sum, avg, min, max, etc.). it is also thenable (await builder) and async iterable (for await (const m of builder)).
magic finders
the framework provides dynamic magic methods for retrieving records by a specific column. simply append the column name in camel case to the findBy prefix.
1const user = await User.findByEmail('test@example.com');2// user: User | null. resolves via findBy => where({ email }).first()
pagination
to paginate records, use the paginate method. it automatically reads the active page query string parameter from the current request context, constructs limit and offset constraints, and returns a Paginator instance.
1const results = await User.where('status', 'active').paginate(15);2// results is a Paginator instance3// directly iterable: for (const user of results) or @foreach(results as user)4// access metadata: results.total, results.currentPage, results.lastPage5// render html: results.links() or @pagination(results)you can also specify a custom page query parameter name as the second argument:
1const users = await User.paginate(15, 'user_page');
latest rows
the latest method orders rows by a column descending and returns either a single model or an array.
1const newestPost = await Post.latest();2const recentPosts = await Post.latest(10);3const recentlyPublished = await Post.latest(10, 'published_at');calling latest() with no arguments returns the single most recent instance (or null). passing a numeric count returns that many model instances as an array. the column defaults to created_at but may be overridden with the second argument.
cloning queries
calling clone() on a ModelQueryBuilder creates an independent copy of the query chain. the cloned instance retains the base table query, active constraint clauses, eager load definitions (with), and relationship metadata:
1const base = User.where('status', 'active').with('profile');2 3// branch query for admins4const admins = await base.clone().where('role', 'admin').get();5 6// branch query for editors7const editors = await base.clone().where('role', 'editor').get();
inserting and updating
to insert a new record, you can instantiate a new model instance, set attributes on it, and call the save method.
1const user = new User();2user.name = 'tarou';3user.email = 'tarou@example.com';4await user.save();5// returns undefined. the instance now carries the assigned primary key6// (if the table has an auto increment column).
mass assignment
dframework provides mass assignment protection via fillable and guarded static model properties matching laravel conventions. by default, when neither fillable nor guarded is defined (or when guarded = []), all attributes are fillable. you may restrict assignable attributes by specifying fillable as an array of allowed column names (an allowlist), or guarded as an array of protected column names (a denylist, or ['*'] to protect all attributes).
creating a new model with a predefined primary key via direct property assignment or constructor and calling save() attempts an insert and throws an error on key collision.
1export default class User extends Model {2 static fillable = ['name', 'email'];3 // or: static guarded = ['is_admin', 'role'];4}when using create() or update(), only attributes allowed by fillable and not protected by guarded will be assigned:
1const user = await User.create({2 name: 'tarou',3 email: 'tarou@example.com',4 is_admin: 1 // not assigned5});
mass assignment helpers
you can check whether a specific attribute key is mass assignable or filter arbitrary input data using the isFillable and filterAttributes static methods:
1// check if a specific key is fillable2User.isFillable('name'); // true3User.isFillable('is_admin'); // false4 5// filter input data to only fillable attributes6const payload = { name: 'tarou', email: 'tarou@example.com', is_admin: 1 };7const safeAttributes = User.filterAttributes(payload);8// safeAttributes: { name: 'tarou', email: 'tarou@example.com' }
lifecycle hooks
models support lifecycle hooks executed during record mutations:
creating(instance)/created(instance): fired before and after an insertupdating(instance)/updated(instance): fired before and after an updatesaving(instance)/saved(instance): fired before and after both inserts and updatesdeleting(instance)/deleted(instance): fired before and after record deletion
hooks can be declared directly as class methods, or registered statically via Model.creating(fn), Model.created(fn), Model.updating(fn), Model.updated(fn), Model.saving(fn), Model.saved(fn), Model.deleting(fn), and Model.deleted(fn).
returning false from a before hook (creating, updating, saving, deleting) aborts the operation and prevents database writes, causing save(), update(), or delete() to resolve to false. on successful completion, these methods resolve to true.
1export default class User extends Model {2 creating() {3 if (!this.email) return false;4 }5 6 created() {7 // executed after record is inserted8 }9}10 11// static registration alternative12User.creating((user) => {13 if (!user.email) return false;14});to update a model, you can either mutate its properties and call save, or use the update method directly.
1const user = await User.find(1);2await user.update({ status: 'active' });3// returns true (or false if aborted by hook). the instance attributes are mutated in place4// before the underlying UPDATE executes.if you only need to update the model updated_at timestamp without modifying other attributes, use the touch method:
1const user = await User.find(1);2await user.touch();3// updates updated_at to the current time in the database and returns true.
first or create
the firstOrCreate method will attempt to locate a record using the given column/value pairs. if the model can not be found, a record will be inserted with the attributes from the first argument, along with any optional attributes from the second argument.
1const user = await User.firstOrCreate(2 { email: 'tarou@example.com' },3 { name: 'tarou' }4);5// user: User. always a hydrated instance; either the matched row6// or the one created by inserting { ...attributes, ...values }.the updateOrCreate method is also available. it returns the existing instance with the update applied, or a new instance if none matched.
increment and decrement
to increment or decrement a column on an existing model instance, use the increment and decrement methods. these methods update the instance attribute in memory and immediately execute an atomic database update statement scoped to the model primary key.
1const user = await User.find(1);2 3// increment total_listens by 1 (default)4await user.increment('total_listens');5 6// increment credits by 10 with extra columns7await user.increment('credits', 10, { updated_at: '2026-09-14' });8 9// decrement credits by 510await user.decrement('credits', 5);
deleting models
to delete a model, call the delete method on a model instance.
1const user = await User.find(1);2await user.delete();3// returns undefined. throws a diagnostic error when the instance4// is missing the primary key value(s) required to scope the delete.
relationships
models can define relationships to other models, allowing you to fluently traverse and query connected data. every relationship method returns a ModelQueryBuilder (chainable, thenable, and awaitable).
one to one
a one to one relationship is defined using the hasOne method. it requires the related model class and the foreign key name.
1import Profile from './Profile.js';2 3export default class User extends Model {4 profile() {5 return this.hasOne(Profile, 'user_id');6 }7}
one to many
a one to many relationship is defined using the hasMany method.
1import Post from './Post.js';2 3export default class User extends Model {4 posts() {5 return this.hasMany(Post, 'user_id');6 }7}
belongs to
the inverse of a hasOne or hasMany relationship is defined using the belongsTo method.
1import User from './User.js';2 3export default class Post extends Model {4 user() {5 return this.belongsTo(User, 'user_id');6 }7}
many to many
a many to many relationship is defined using the belongsToMany method. by convention, the framework automatically infers the pivot table name by sorting both model names alphabetically in snake_case (e.g. role_user), and derives foreign pivot keys as ${singular}_id (e.g. user_id and role_id).
1import Role from './Role.js';2 3export default class User extends Model {4 roles() {5 return this.belongsToMany(Role);6 }7}you can optionally pass explicit pivot table and key arguments if your schema uses custom names:
1return this.belongsToMany(Role, 'user_roles', 'usr_id', 'role_id', 'id', 'id');retrieving pivot data and withPivot
when querying or eager loading a belongsToMany relationship, pivot table attributes are automatically attached to model.pivot on each related instance without polluting the primary model's own attributes.
to load additional columns from the pivot table, chain withPivot:
1export default class User extends Model {2 roles() {3 return this.belongsToMany(Role).withPivot('expires_at', 'level');4 }5}6 7const user = await User.with('roles').first();8console.log(user.roles[0].pivot.level); // 99attaching, detaching, and syncing pivot records
the relationship query builder provides convenient helpers to manage intermediate pivot records:
1const user = await User.find(1);2 3// attach a single id with optional pivot attributes4await user.roles().attach(2, { level: 'admin' });5 6// attach multiple ids7await user.roles().attach([3, 4]);8 9// attach multiple ids with individual attributes10await user.roles().attach({11 5: { level: 'manager' },12 6: { level: 'viewer' }13});14 15// detach a single id or multiple ids16await user.roles().detach(2);17await user.roles().detach([3, 4]);18 19// detach all related records20await user.roles().detach();21 22// sync associations (detaches missing ids, attaches new ones, updates existing attributes)23const diff = await user.roles().sync({24 1: { level: 'admin' },25 5: { level: 'editor' }26});27// diff: { attached: ['5'], detached: ['6'], updated: ['1'] }once a relationship is defined, you can query it by calling the method, which returns a ModelQueryBuilder.
1const activePosts = await user.posts().where('status', 'active').get();2// activePosts: Array<Post>. empty [] when no posts match.awaiting a hasMany/belongsTo/hasOne relation builder returns the hydrated model(s) directly (array for hasMany, single model or null for hasOne/belongsTo), so you can also write:
1const profile = await user.profile(); // Profile | null2const posts = await user.posts(); // Array<Post>you can also await the property directly without calling it as a function:
1const posts = await user.posts;automatic in memory caching and lazy fetching
the framework automatically caches eager loaded relations and handles lazy loading transparently when awaited:
- if the relation was eager loaded with
with('posts')orload('posts'),await user.posts(orawait user.posts(), or concurrentPromise.all([user.profile(), user.posts()])) resolves immediately in memory with zero database queries. - if the relation was not eager loaded, awaiting the relation executes a single database query on demand.
- if the model has a null foreign or local key (e.g. unassigned relationship), unconstrained relation queries resolve immediately to
nullor[]without querying the database.
because relation properties are callable proxies (enabling both in memory collection access and fluent query chaining like user.posts().where('status', 'active')), Array.isArray(user.posts) will return false. you do not need manual Array.isArray branches to optimize data access; simply await user.posts or await user.posts() to get the hydrated collection whether it was preloaded or not.
relationship property assignment and mutation
relation properties support both getters and setters. you can directly assign eager loaded collections or related models on a model instance:
1user.posts = [customPostA, customPostB];2user.profile = new Profile({ bio: 'updated bio' });mutating relation properties updates relation state and is preserved across serialization with user.toJSON().
eager loading
when you access a relationship as a property, the framework will read the preloaded relation data. to prevent the n+1 query problem, use the with method to eager load relationships when fetching the parent models.
1const users = await User.with('profile', 'posts').limit(10).get();2// users: Array<User>. each user already has _relations populated.3 4const user = await User.with('profile').find(1);5// user: User | null. loaded with profile relation populated.6 7for (const user of users) {8 // accessing user.posts does not trigger an additional query9 console.log(user.posts); // Array<Post>10}you can eager load nested relationships using dot notation.
1const users = await User.with('posts.comments').get();you can also constrain eager loaded relationships by passing a query builder closure to filter or sort the related models. any ordering applied in the closure is strictly preserved:
1// single relation with query constraint closure2const users = await User.with('posts', (query) => {3 query.where('published', true).orderBy('views', 'desc');4}).get();5 6// multiple relations with constraints using object syntax7const authors = await Author.with({8 articles: (query) => query.where('status', 'live').orderBy('created_at', 'desc'),9 comments: (query) => query.orderBy('id', 'asc')10}).get();during local development, the debug bar automatically detects repeated queries from missing eager loading and displays an interactive modal with the recommended `with()` syntax for the active callsite.
lazy eager loading
if you have already retrieved a model instance and need to eager load a relationship after the fact, use the load method.
1const user = await User.find(1);2const sameUser = await user.load('posts', 'profile');3// returns the same instance with the relations populated.
serialization
when you cast a model to a json string or return it from a route, the framework automatically converts it using the toJSON method. this method returns a plain object with all attributes and eager loaded relationships, applying the hidden list.
hiding attributes
you may wish to hide certain attributes, such as passwords or sensitive tokens, from the serialized output. define a static hidden array on your model to exclude these attributes.
1export default class User extends Model {2 static hidden = ['password', 'secret', 'api_key'];3}by default, the framework automatically hides `password`, `token`, `secret`, `api_key`, and `remember_token` when serializing models with `toJSON()`. subclasses defining additional hidden attributes inherit these defaults automatically. hidden attributes only apply to `toJSON()` serialization (such as api responses) and never restrict direct property access in code (such as reading `user.password` during authentication).
visible attributes
alternatively, you can define an allowlist of attributes that should be included when serializing the model using the static visible property. when visible is defined, only the specified attributes, relations, and pivot keys are serialized with toJSON(), and hidden is ignored.
1export default class User extends Model {2 static visible = ['id', 'name', 'email', 'posts', 'pivot'];3}
mutations
models dynamically proxy property accesses to their underlying attribute store. you interact with the model instance as if it were a plain javascript object.
if you need to manually encrypt a sensitive field that isn't handled by the query builder's auto hashing configuration, you can use the hash method on the model instance.
1const user = await User.find(1);2user.custom_secret = 'plain-text';3await user.hash('custom_secret');4await user.save();5// hash() mutates the instance in place; save() persists it.
