Start Here
Part 2: Your First Model
You’ll add a Post model, create the posts table via a migration, seed two records, and build index/show actions backed by Wheels’ ORM.
You’ll learn:
- How Wheels models map to tables, and where configuration lives
- How migrations describe schema changes in ordered CFC files
- How
seedOnce()keeps seed data idempotent - How finders, scopes, and enums combine into query-building
Estimated time: 25 minutes.
Where we left off
Section titled “Where we left off”At the end of Part 1 you had a running blog app with a /hello route. There’s no application schema yet, no models with data, and no Posts controller. The tree below highlights only the directories you’ll touch in this chapter — the rest of the layout from Part 1 (app/events/, app/jobs/, public/, tests/, vendor/, root-level dotfiles, and so on) is unchanged and elided here for focus.
Directoryblog
Directoryapp
Directorycontrollers
- Main.cfc
Directorymigrator
- migrations
Directorymodels/
- …
Directoryviews
- helpers.cfm
- layout.cfm
Directorymain
- index.cfm
- hello.cfm
Directoryconfig
- app.cfm
- environment.cfm
- routes.cfm
- settings.cfm
Directorydb
- development.sqlite
- test.sqlite
Main.cfc has index and hello actions. config/routes.cfm has a /hello named route alongside the wildcard and root. The SQLite database file exists but has no application tables. You’ll fix all of that here.
The Controller and Model base classes that your code extends (e.g. component extends="Controller") live under vendor/wheels/, not in app/. They’re framework files; wheels new doesn’t put them in your project tree. You won’t need to touch them.
Generate the Post model
Section titled “Generate the Post model”wheels generate model Post title:string body:text writes both the model CFC and a migration for you. The generator output is intentionally slim relative to what this tutorial needs — it doesn’t include the enum for status or the publishedAt field, and it ships with default validatesPresenceOf rules on the columns you passed in that we’ll drop now and bring back in Part 4 once we’ve got more validation context. You’ll augment it after.
-
Run the generator:
Terminal window wheels generate model Post title:string body:textThat creates
app/models/Post.cfcand a migration file underapp/migrator/migrations/with a current timestamp. The generated model is short but not empty — it ships withvalidatesPresenceOf("title,body")already populated for the two columns you passed in. -
Replace the contents of
app/models/Post.cfcwith the version below — we drop the auto-generated validations for now (Part 4 brings them back) and add thestatusenum we’ll use to filter posts:component extends="Model" {function config() {enum(property="status", values="draft,published,archived");}}
component extends="Model" makes this a Wheels model — by convention, Post.cfc maps to a posts table. The config() function is where model configuration (associations, validations, callbacks, enums, scopes) goes.
enum(property="status", values="draft,published,archived") declares that status can only hold one of three values. In return Wheels auto-generates:
- Scopes:
Post.draft(),Post.published(),Post.archived()— chainable query fragments. - Checkers:
post.isDraft(),post.isPublished(),post.isArchived()— per-instance booleans.
You’ll use the published() scope in a minute.
Augment the migration
Section titled “Augment the migration”Migrations are ordered, reversible schema changes. Each one is a CFC in app/migrator/migrations/ whose filename starts with a timestamp (YYYYMMDDHHMMSS_description.cfc). wheels migrate latest runs every pending migration in timestamp order.
The generator already wrote a migration for you with title and body columns. We need to add status and publishedAt so the model and table line up.
-
Open the migration file the generator created (it’s the most recent one in
app/migrator/migrations/, named<timestamp>_create_posts_table.cfc). Rename it to20260419120000_create_posts_table.cfcand replace its contents with the version below.component extends="wheels.migrator.Migration" hint="Create posts table" {function up() {transaction {try {t = createTable(name="posts");t.string(columnNames="title", default="", allowNull=true, limit=255);t.text(columnNames="body", default="", allowNull=true);t.string(columnNames="status", default="draft", allowNull=false, limit=20);t.datetime(columnNames="publishedAt", allowNull=true);t.timestamps();t.create();} catch (any e) {local.exception = e;}if (StructKeyExists(local, "exception")) {transaction action="rollback";Throw(errorCode="1", detail=local.exception.detail, message=local.exception.message, type="any");} else {transaction action="commit";}}}function down() {transaction {dropTable("posts");transaction action="commit";}}}
A few things to notice:
up()runs when applying the migration;down()reverses it.createTable(name="posts")returns a builder. You add typed columns on it, then callt.create()to emit the DDL.t.timestamps()adds threedatetimecolumns at once:createdAt,updatedAt, anddeletedAt. The first two are obvious bookkeeping;deletedAtis the soft-delete marker (covered in Part 5). Don’t add any of them by hand.statusis stored as a short string matching the enum values. Wheels doesn’t require a database-level enum type.- The
transaction { try ... catch ... }shape looks verbose. It’s what the generator produces too; it ensures partial schema changes roll back cleanly.
Run the migration
Section titled “Run the migration”wheels migrate latest needs the dev server running — it reaches into the app over HTTP to execute migrations.
-
Make sure the server is up:
your shell wheels startIf it’s already running, this is a no-op.
-
Apply pending migrations:
your shell wheels migrate latestExpected output looks roughly like:
expected output Migrating from 0 up to 20260419120000.-------- 20260419120000_create_posts_table -----------------Created table postsThe exact wording can drift across snapshots; what matters is that you see
Created table posts.
The posts table now exists in db/development.sqlite.
Seed the database
Section titled “Seed the database”Wheels loads seed data from app/db/seeds.cfm (shared) and optionally app/db/seeds/<environment>.cfm (environment-specific). The seedOnce() helper is idempotent: it checks uniqueProperties first and only inserts when the record is missing. Running wheels seed repeatedly is safe.
-
Create the
app/db/directory (it isn’t scaffolded bywheels new), then createapp/db/seeds.cfm:your shell mkdir -p app/dbapp/db/seeds.cfm <cfscript>seedOnce(modelName="Post", uniqueProperties="title", properties={title: "Hello world",body: "My first Wheels post.",status: "published",publishedAt: Now()});seedOnce(modelName="Post", uniqueProperties="title", properties={title: "Learning Wheels",body: "Working through the tutorial.",status: "published",publishedAt: Now()});</cfscript> -
Run the seed:
your shell wheels seedExpected output mentions two records seeded. Run it again — both will be skipped because the titles already exist.
Build the index and show actions
Section titled “Build the index and show actions”Controllers pull data and hand it to views. You’ll add a Posts controller with two actions: index (list all posts) and show (one post by ID).
-
Create
app/controllers/Posts.cfc:component extends="Controller" {function index() {posts = model("Post").published().findAll(order="publishedAt DESC");}function show() {post = model("Post").findByKey(params.key);}}
What the code does:
model("Post")returns thePostmodel object you can call finders on..published()is the scope auto-generated from thestatusenum — equivalent to “where status = ‘published’”..findAll(order="publishedAt DESC")executes the query and returns a query object (not an array).findByKey(params.key)looks up one record by primary key.params.keyis the URL segment Wheels extracts from resource routes (e.g./posts/7→params.key = 7).
Add the routes
Section titled “Add the routes”Now wire URLs to those actions.
-
Update
config/routes.cfm:mapper().resources(name="posts", only="index,show").get(name="hello", pattern="/hello", to="main##hello").wildcard().root(to="posts##index", method="get").end();
.resources(name="posts", only="index,show") declares the two RESTful routes you need: GET /posts → posts##index, GET /posts/:key → posts##show. Route order matters — .resources before .wildcard(), and .root(...) last. The root now points at posts##index instead of main##index, so / lists posts.
Write the views
Section titled “Write the views”Finally, the templates.
-
Create
app/views/posts/index.cfm:app/views/posts/index.cfm <cfparam name="posts" default=""><cfoutput><h1>Posts</h1><cfloop query="posts"><article><h2>#linkTo(route="post", key=posts.id, text=posts.title)#</h2><p>#posts.body#</p></article></cfloop></cfoutput> -
Create
app/views/posts/show.cfm:app/views/posts/show.cfm <cfparam name="post" default=""><cfoutput><h1>#post.title#</h1><p>#post.body#</p><p>#linkTo(route="posts", text="← all posts")#</p></cfoutput>
A few things to notice:
<cfparam name="posts" default="">declares the variable the view expects. Every variable handed from a controller to a view should becfparam’d at the top of the view.<cfloop query="posts">iterates the query returned byfindAll(). Inside the loop,posts.title,posts.body, andposts.idrefer to the current row’s columns.linkTo(route="post", key=posts.id, text=posts.title)generates a link to/posts/:keyusing the singular route namepostthat.resources("posts")defined for you.linkTo(route="posts")(plural) points back to the index.
Smoke test
Section titled “Smoke test”Confirm your CLI is the version this tutorial targets:
wheels --versionThe verify-docs harness runs this to confirm your wheels CLI is installed and reports a Wheels version.
Checkpoint
Section titled “Checkpoint”Reload the app and hit the three URLs below. Each should return the response listed.
wheels reloadcurl http://localhost:8080/posts— returns the index page with both posts rendered inside<article>tags.curl http://localhost:8080/posts/1— returns the show page for the first post.curl http://localhost:8080/— the root now renders the posts index (because you changed the root route).
Troubleshooting
Section titled “Troubleshooting”Data source wheelstestdb not found — the migration didn’t run, so the posts table doesn’t exist. Make sure the dev server is running, then run wheels migrate latest.
model 'Post' is undefined — Wheels caches model discovery. After adding app/models/Post.cfc you must run wheels reload (or append ?reload=true&password=... to any URL).
No rows on the index page — the seed file didn’t run. Run wheels seed and check the output mentions two records. If the seed ran but the page is still empty, verify both seed records have status: "published" — the controller filters by .published().
What’s next
Section titled “What’s next”In Part 3: CRUD Scaffold you’ll throw most of Part 2’s hand-written code away and let wheels generate scaffold produce the full create/read/update/delete stack. You’ll also tour the Wheels package system and get a first look at Hotwire ahead of Part 4, where Turbo Frames land for real.
Want a reference-level tour of what you just built? See Models and the ORM and Migrations.