v0.21

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 directly
4user.metadata.theme = 'dark';
5user.metadata.notifications = true;
6
7// save persists the stringified json payload to the database
8await 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 instance
3// directly iterable: for (const user of results) or @foreach(results as user)
4// access metadata: results.total, results.currentPage, results.lastPage
5// 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 admins
4const admins = await base.clone().where('role', 'admin').get();
5
6// branch query for editors
7const 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 key
6// (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 assigned
5});

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 fillable
2User.isFillable('name'); // true
3User.isFillable('is_admin'); // false
4
5// filter input data to only fillable attributes
6const 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:

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 inserted
8 }
9}
10
11// static registration alternative
12User.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 place
4// 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 row
6// 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 columns
7await user.increment('credits', 10, { updated_at: '2026-09-14' });
8
9// decrement credits by 5
10await 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 instance
4// 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); // 99

attaching, 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 attributes
4await user.roles().attach(2, { level: 'admin' });
5
6// attach multiple ids
7await user.roles().attach([3, 4]);
8
9// attach multiple ids with individual attributes
10await user.roles().attach({
11 5: { level: 'manager' },
12 6: { level: 'viewer' }
13});
14
15// detach a single id or multiple ids
16await user.roles().detach(2);
17await user.roles().detach([3, 4]);
18
19// detach all related records
20await 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 | null
2const 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:

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 query
9 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 closure
2const users = await User.with('posts', (query) => {
3 query.where('published', true).orderBy('views', 'desc');
4}).get();
5
6// multiple relations with constraints using object syntax
7const authors = await Author.with({
8 articles: (query) => query.where('status', 'live').orderBy('created_at', 'desc'),
9 comments: (query) => query.orderBy('id', 'asc')
10}).get();
TIP

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}
NOTE

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.