Basics
Associations
Associations are how models reach each other. You declare the relationship once in config() and Wheels generates the dynamic methods — post.comments(), comment.post(), post.createComment(...) — that let you navigate it. This page walks the three kinds, the options that shape their behavior, and the patterns you’ll reach for daily: eager loading, nested creation, and cascading deletes.
You’ll learn:
- The three association kinds —
hasMany,belongsTo,hasOne— and which foreign key each expects - The dynamic methods Wheels generates for each side of a relationship
- How to cascade deletes with
dependent - How to override model name and foreign key when convention doesn’t match your schema
- How to avoid N+1 queries with
include
The three kinds
Section titled “The three kinds”Every relationship in Wheels is one of three declarations. You always declare both sides — the parent model names the child side with hasMany or hasOne, and the child model names the parent side with belongsTo. The foreign key always lives on the child table.
| Association | Means | Foreign key lives on |
|---|---|---|
hasMany(name="comments") | ”I own many” | Child table (comments.postId) |
belongsTo(name="post") | ”I reference one” | This table (this.postId) |
hasOne(name="profile") | ”I own exactly one” | Child table (profiles.userId) |
The pair Post hasMany(name="comments") + Comment belongsTo(name="post") is the most common shape in any Wheels app. You’ll see it in the tutorial, and you’ll write it dozens of times in your own code.
hasMany + belongsTo (the most common pair)
Section titled “hasMany + belongsTo (the most common pair)”Declare the parent side on Post.cfc. The name argument names the association — it becomes the method name on every Post instance. dependent="delete" tells Wheels to clean up comments when a post is deleted (more on cascade options below).
component extends="Model" { function config() { hasMany(name="comments", dependent="delete"); validatesPresenceOf(properties="title,body"); }}Declare the child side on Comment.cfc. The name argument refers to the parent — post — and by convention Wheels looks for a postId column on the comments table to store the foreign key.
component extends="Model" { function config() { belongsTo(name="post"); validatesPresenceOf(properties="author,body"); }}Once the pair is declared, every Post instance carries a cluster of generated methods:
post.comments()— returns a query of comments for that post (not an array — loop it with<cfloop query="...">)post.comments(where="approved = 1", order="createdAt DESC")— same query with extra filterspost.createComment(author="Alice", body="...")— instantiate, setpostId, save, return the new Commentpost.newComment(...)— same but without saving (returns an unsaved instance for further edits)post.addComment(existingComment)— link an existing Comment by setting itspostIdpost.removeComment(existingComment)— unlink or delete, per thedependentsettingpost.deleteComment(existingComment)— delete the comment from the databasepost.hasComments()— boolean, true when at least one row existspost.commentCount()— integer count, cheaper thanpost.comments().recordCountfor a quick checkpost.findOneComment(where="...")— returns a single Comment orfalse
And every Comment instance carries the other side:
comment.post()— the parent Post instance, orfalseif the foreign key doesn’t resolvecomment.hasPost()— boolean
The association methods read like English because they use the association name, not the model name. post.createComment(...) reads better than model("Comment").create(postId=post.id, ...) — and it means you never have to remember the foreign key column name.
dependent options
Section titled “dependent options”dependent tells Wheels what to do with child records when the parent is deleted. Only hasMany and hasOne accept it — belongsTo can’t cascade upward.
dependent="delete"— instantiate each child and calldelete()on it. This runs the child’sbeforeDelete/afterDeletecallbacks, so any cleanup logic on the child model (clearing related caches, sending notifications) still fires. Slower when there are many children.dependent="deleteAll"— issue a singleDELETE FROM children WHERE parentId = ?statement. Fast, but skips the child’s callbacks.dependent="remove"— instantiate each child and set its foreign key toNULL. Child’sbeforeUpdate/afterUpdatefire. Use when children can survive without a parent.dependent="removeAll"— singleUPDATE children SET parentId = NULL WHERE parentId = ?. Fast, skips callbacks.- Default (not set) — no cascade. Deleting the parent leaves orphaned child rows whose foreign key points at a row that no longer exists. You almost always want a
dependentvalue, even on small associations.
Choose delete (or deleteAll if the children don’t have callbacks) for strict ownership — comments, line items, anything that can’t outlive its parent. Choose remove or removeAll for looser links — employees who survive their department, posts that survive their author, and so on.
hasOne
Section titled “hasOne”hasOne is hasMany’s one-to-one sibling. The relationship and the foreign key placement are identical — the child still carries the foreign key. The only difference is that the parent’s association method returns a single instance (or false) instead of a query.
component extends="Model" { function config() { hasOne(name="profile", dependent="delete"); }}component extends="Model" { function config() { belongsTo(name="user"); }}The generated methods mirror hasMany, scaled to a single record:
user.profile()— returns the Profile instance orfalseuser.hasProfile()— booleanuser.createProfile(bio="...", location="...")— create and linkuser.newProfile(...)— unsaved instanceuser.setProfile(existingProfile)— link an existing Profileuser.removeProfile()— unlink (nullify foreign key)user.deleteProfile()— delete the Profile row
Use hasOne when the enforcement is “exactly one.” A User has one Profile. An Order has one ShippingAddress. If the enforcement is fuzzier — “a user has one active subscription at a time, but historically several” — model the collection with hasMany and scope the query.
Foreign-key overrides
Section titled “Foreign-key overrides”Wheels infers the foreign key from the association name. hasMany(name="comments") on Post expects comments.postId. belongsTo(name="author") on Post expects posts.authorId (not posts.userId, even if Author is a User under the hood).
When the column name doesn’t match, override it with foreignKey:
component extends="Model" { function config() { hasMany(name="comments", foreignKey="blogPostId"); }}When the association name doesn’t singularize to the model name, combine foreignKey with modelName:
component extends="Model" { function config() { hasMany(name="authoredBooks", modelName="Book", foreignKey="authorId"); hasMany(name="editedBooks", modelName="Book", foreignKey="editorId"); }}Now the same User model exposes two associations against the same books table, distinguished by foreign key. user.authoredBooks() and user.editedBooks() return different queries.
belongsTo also accepts modelName — useful when the parent side is aliased:
component extends="Model" { function config() { belongsTo(name="author", modelName="User", foreignKey="authorId"); belongsTo(name="editor", modelName="User", foreignKey="editorId"); }}Nested creation (create-through)
Section titled “Nested creation (create-through)”When you already have a parent instance, use its association method to create children. The foreign key is set for you and the relationship is explicit at the call site.
component extends="Controller" { function create() { post = model("Post").findByKey(params.postKey); comment = post.createComment(params.comment); if (!comment.hasErrors()) { redirectTo(route="post", key=post.id); } else { renderView(action="show"); } }}Compare with the non-association form:
comment = model("Comment").create( postId=params.postKey, author=params.comment.author, body=params.comment.body);The first form is shorter, less error-prone (you can’t forget the foreign key), and reads top-to-bottom like the relationship it’s modeling. Prefer it whenever you have the parent in hand.
Eager loading with include
Section titled “Eager loading with include”The classic N+1 problem: you load a list of posts, then touch each post’s comments inside the view. Each touch fires a new query, and the page crawls.
posts = model("Post").findAll();// in the view:<cfloop query="posts"> ##posts.commentCount()## <!-- one query per post --></cfloop>include fixes this by joining the associated table into the finder’s query. One SQL statement loads posts and their comments together.
component extends="Controller" { function index() { posts = model("Post").findAll( include="comments", order="publishedAt DESC" ); }}Include multiple associations by comma-separating: include="comments,author". Include nested associations with parentheses: include="comments(author)" — load comments for each post and the author for each comment, all in one query.
include works with any finder that accepts a where clause — findAll, findOne, count — and it combines cleanly with scopes and the query builder. Use it whenever a view iterates an association on each row.
Many-to-many (through a join model)
Section titled “Many-to-many (through a join model)”Wheels handles many-to-many through a real join model plus the shortcut argument on hasMany. If a User has many Roles via a UserRole join table, model the relationship as three classes:
component extends="Model" { function config() { hasMany(name="userRoles", shortcut="roles"); }}A single hasMany with shortcut provides both access paths: user.userRoles() for the join rows and user.roles() for the far-side records. Behind the scenes Wheels walks both association chains to build the query. The same declaration also works correctly with include — model("User").findAll(include="userRoles") joins the join table as expected.
The join model needs both belongsTo declarations:
component extends="Model" { function config() { belongsTo(name="user"); belongsTo(name="role"); }}And the Role side mirrors the User side:
component extends="Model" { function config() { hasMany(name="userRoles", shortcut="users"); }}The through argument is available for rare cases where the association names don’t follow singular/plural convention — see the source comments in vendor/wheels/model/associations.cfc — but for the common case, shortcut alone is enough.
Polymorphic associations
Section titled “Polymorphic associations”A polymorphic association lets the same child model belong to more than one parent type. The classic case is Comment belonging to either a Post or a Photo — the comments table carries both a commentableId foreign key and a commentableType string column that names the parent’s model.
Declare the child side with polymorphic=true:
component extends="Model" { function config() { belongsTo(name="commentable", polymorphic=true); }}Declare each parent side with as naming the polymorphic interface:
component extends="Model" { function config() { hasMany(name="comments", as="commentable", dependent="delete"); }}component extends="Model" { function config() { hasMany(name="comments", as="commentable", dependent="delete"); }}Wheels resolves the parent at runtime by reading commentableType on each row. comment.commentable() returns a Post or a Photo depending on the row’s type column.