Skip to content

Basics

Models and the ORM

Every Wheels app spends most of its time talking to models. A model is a CFC that extends Model, maps to a database table by convention, and carries the behavior for the rows it represents — queries, validations, callbacks, and any domain logic you choose to keep near the data. This page walks the day-to-day surface: defining a model, the finders, the persistence methods, and the lifecycle callbacks that fire around every save.

You’ll learn:

  • How to define a model and which conventions Wheels infers from the class name
  • The finder methods — findAll, findOne, findByKey, exists, count — and what each returns
  • How new, create, save, update, and delete move records in and out of the database
  • The full list of lifecycle callbacks and how to register them in config()
  • Where to put custom methods so they read naturally from controllers and views

A model is a CFC in app/models/ that extends the framework’s Model component. The file name is singular PascalCase — Post.cfc, User.cfc, OrderLineItem.cfc — and Wheels pluralizes it to find the table (posts, users, order_line_items). The primary key is assumed to be a column named id. You don’t declare columns in the CFC; Wheels inspects the table at app start and exposes every column as a property on the instance.

Everything the model needs to know about itself — associations, validations, callbacks, defaults — lives in config(). The framework calls config() once per model, wires up whatever you registered, and then the rest of the class is free for custom methods.

component extends="Model" {
function config() {
validatesPresenceOf(properties="title,body");
hasMany(name="comments", dependent="delete");
beforeSave("sanitizeTitle");
}
private function sanitizeTitle() {
this.title = Trim(this.title);
}
}

That file goes at app/models/Post.cfc. Wheels infers the table name posts and the primary key id. The hasMany registers an association with a Comments model; the validatesPresenceOf runs on every save; beforeSave triggers the private method before the record is written. No boilerplate, no schema mirror — the CFC is the behavior, the table is the schema.

When the table, key, or column names don’t match the defaults, override them in config(). Keep overrides to a minimum — every override is a rule the next reader has to learn — but reach for them freely when you’re mapping onto a legacy schema.

  • tableName("old_blog_entries") — non-convention table name.
  • setPrimaryKey("entryId") — non-id primary key.
  • dataSource("legacy_db") — non-default datasource. See Database and Multiple Datasources when this lands for the wider multi-database story.
  • property(name="publishedAt", column="pub_date") — the property on the model is publishedAt, the column in the table is pub_date. The view and controller never see the column name.
component extends="Model" {
function config() {
tableName("old_blog_entries");
setPrimaryKey("entryId");
property(name="publishedAt", column="pub_date");
}
}

Composite keys are also supported — pass a comma-separated list to setPrimaryKey("tenantId,entryId"). Most apps never need them.

The finders read from the database. Every finder below is a method on the class, called via model("Name"). The calls return different shapes depending on whether you’re loading many, one, or just asking a question.

FinderReturnsUse when
findAll(where="...", order="...", ...)Query objectLoading many records
findOne(where="...")Single model instance or falseGetting one by condition
findByKey(key)Single model instance or falseLoading by primary key
exists(where="...")BooleanChecking existence without loading
count(where="...")IntegerCounting without loading

findAll returns a query object, not an array. Loop it with <cfloop query="..."> or pass returnAs="objects" when you want an array of model instances (at the cost of per-row object construction). findOne and findByKey return a single model instance, or false when nothing matches — always guard with IsObject(record) before using it. For conditions more complex than a simple where clause, see Query Builder and Scopes.

component extends="Controller" {
function index() {
posts = model("Post").findAll(
order="publishedAt DESC",
perPage=25,
page=params.page ?: 1
);
}
function show() {
post = model("Post").findByKey(params.key);
if (!IsObject(post)) {
redirectTo(route="posts", error="Not found");
}
}
}

Pagination is built into findAll: pass page and perPage and the framework runs a count query, a primary-key query, and a hydration query behind the scenes. Pair with the view pagination helpers (paginationNav()) to render navigation.

Two patterns, chosen by whether you need to inspect or mutate the record before it’s saved.

  • new() then save() — construct an in-memory instance, set or tweak properties, then call save(). The controller between the two lets you assign fields the user can’t pass (userId, tenantId) or run server-side logic.
  • create() — shorthand for new() + save() in one call. Use it when every field on the record comes straight from the form and there’s nothing to adjust.
component extends="Controller" {
function create() {
post = model("Post").new(params.post);
post.userId = session.userId;
if (post.save()) {
redirectTo(route="post", key=post.id);
} else {
renderView(action="new");
}
}
}

Both save() and create() run validations first; if any fail, the write is skipped and the record is returned with errors attached. See Validation and Error Display for how that flow surfaces to the view.

update() is a mass-assignment shortcut: pass a struct and it applies every key, runs validations, and saves. You can also pass individual named arguments (post.update(title="...", body="...")) when the change is narrow.

component extends="Controller" {
function update() {
post = model("Post").findByKey(params.key);
if (post.update(params.post)) {
redirectTo(route="post", key=post.id);
} else {
renderView(action="edit");
}
}
}

If you’ve already mutated the instance in memory — set a property directly, run a callback, whatever — a bare save() writes the current state. update() is only syntactic sugar for “assign these fields and save.”

For bulk edits without loading rows into memory, model("Post").updateAll(where="status='draft'", status="review") runs a single SQL statement and returns the affected row count. It skips callbacks by default.

delete() removes the row and returns true on success. It runs beforeDelete and afterDelete callbacks and honors dependent="delete" on any hasMany or hasOne association — child rows are removed in the same transaction.

component extends="Controller" {
function delete() {
post = model("Post").findByKey(params.key);
post.delete();
redirectTo(route="posts");
}
}

For a key-only delete without loading the record, use model("Post").deleteByKey(params.key). For bulk deletes, deleteAll(where="...") runs a single statement and returns the row count; like updateAll, it skips callbacks by default.

Callbacks register a method to run at a specific point in the record’s lifecycle. For the write-cycle callbacks the method takes no arguments (the record is this), returns nothing, and can mutate any property on this before the write happens. Return false from a before* callback to cancel the operation. (afterFind is the exception — see below.)

CallbackWhen it fires
afterInitializationAfter a model instance is constructed (from new() or from a finder)
afterNewAfter new() specifically
afterFindAfter each record loaded from the database
beforeValidation / afterValidationAround every validation run
beforeValidationOnCreate / afterValidationOnCreateValidation on new records only
beforeValidationOnUpdate / afterValidationOnUpdateValidation on existing records only
beforeSave / afterSaveAround every INSERT or UPDATE
beforeCreate / afterCreateAround INSERT only
beforeUpdate / afterUpdateAround UPDATE only
beforeDelete / afterDeleteAround DELETE

Register callbacks in config()beforeSave("methodName") or beforeSave(methods="one,two") when you want several. The methods live on the same component and should be private — invocation is internal, so public also works, but a public callback method leaks onto the model’s callable API.

component extends="Model" {
function config() {
beforeSave("generateSlug");
afterCreate("notifyAdmin");
}
private function generateSlug() {
if (!Len(this.slug ?: "")) {
this.slug = LCase(Replace(this.title, " ", "-", "all"));
}
}
private function notifyAdmin() {
// enqueue a job here
}
}

The ordering is strict: beforeValidation → validation runs → afterValidationbeforeSave → (beforeCreate or beforeUpdate) → SQL write → (afterCreate or afterUpdate) → afterSave. If any before* callback returns false, the chain stops and the write is skipped.

afterFind is the one callback that fires without a write: it runs once per record returned by a finder, which makes it the right place for decorating loaded records with derived values. Its contract differs by what the finder returns:

  • Object-returning finders (findOne, findByKey, findAll(returnAs="objects")) — the callback receives the record’s properties as named arguments. You can mutate this directly, or return a struct — the returned keys are applied back onto the object via setProperties().
  • Query-returning finders (findAll by default) — there is no object per row. The callback receives the row’s columns as named arguments and decorates by returning a struct; any keys you return are merged back into the row (new keys become new query columns). Mutating this does nothing here.

Both modes receive named arguments and both honor a returned struct, so the portable pattern — one callback that decorates objects and query rows alike — is to compute from arguments and return a struct:

component extends="Model" {
function config() {
afterFind("setFullName");
}
private function setFullName() {
return {fullName: arguments.firstName & " " & arguments.lastName};
}
}

Any public function on the model is callable from a controller, a view, or another model. This is where business logic belongs — keep controllers thin and let the model answer questions about itself.

component extends="Model" {
public string function displayTitle() {
return Len(this.title) > 60 ? Left(this.title, 57) & "..." : this.title;
}
public boolean function isPublished() {
return this.status == "published"
AND IsDate(this.publishedAt)
AND this.publishedAt <= Now();
}
}

In a view: ##post.displayTitle()##. In a controller: if (post.isPublished()) { ... }. The method has full access to this.* — every column on the record, every association, every other method on the class.

After save() the instance’s primary key is populated and any property you set in a callback is current. Columns the database computed on your behalf — DEFAULT expressions, triggers, generated columns — are not refreshed automatically. When you need the authoritative post-save state, call post.reload() to re-query the row and overwrite the in-memory instance.

This is rarely needed. Most apps don’t use DB-computed columns, and when they do, the reload is usually only required in tests or in flows that show the record back to the user immediately.