Start Here
Part 3: CRUD Scaffold
You’ll delete the hand-written controller and views from Part 2, regenerate them as a full CRUD scaffold, and learn how Wheels packages activate — with a first look at Hotwire.
You’ll learn:
- What
wheels generate scaffoldproduces — 7 actions, 4 views, one shared form partial - How the standard REST actions (
index/show/new/create/edit/update/delete) fit together - How route model binding turns
params.keyintoparams.postautomatically - What packages are and how the
vendor/activation model works
Estimated time: 25 minutes.
Where we left off
Section titled “Where we left off”Part 2 ended with a minimal slice: one model, one migration, one seed file, a two-action controller, and two views.
Directoryblog
Directoryapp
Directorycontrollers
- Controller.cfc
- Main.cfc
- Posts.cfc
Directorydb
- seeds.cfm
Directorymigrator
Directorymigrations
- 20260419120000_create_posts_table.cfc
Directorymodels
- Post.cfc
Directoryviews
- layout.cfm
Directorymain
- index.cfm
- hello.cfm
Directoryposts
- index.cfm
- show.cfm
Directoryconfig
- routes.cfm
- settings.cfm
Directorydb
- development.sqlite
Schema recap: posts(id, title, body, status, publishedAt, createdAt, updatedAt) with status constrained to draft, published, or archived. config/routes.cfm has .resources(name="posts", only="index,show").
What scaffold does
Section titled “What scaffold does”Scaffolds generate the seven standard REST actions on a controller, views for the four user-facing actions, and a shared form partial. A scaffold for Post produces:
app/controllers/Posts.cfcwithindex,show,new,create,edit,update,deleteapp/views/posts/index.cfm,show.cfm,new.cfm,edit.cfmapp/views/posts/_form.cfm— shared bynewandedit
Because scaffolds follow convention, a plain .resources("posts") line in config/routes.cfm is all the routing you need. Wheels maps GET /posts → index, GET /posts/new → new, POST /posts → create, GET /posts/:key → show, GET /posts/:key/edit → edit, PATCH /posts/:key → update, and DELETE /posts/:key → delete.
Route model binding kicks in on the actions that take a key. For show, edit, update, and delete, Wheels finds the Post matching params.key before your action runs and hands it to you as params.post. No findByKey in the controller.
Delete Part 2’s handiwork
Section titled “Delete Part 2’s handiwork”Drop the hand-written controller and views. The model, migration, seed file, and the resources("posts") route stay.
wheels destroy Posts controller --forcewheels destroy Posts view --forceThe argument order is <name> [type] — Posts controller, not controller Posts. destroy controller removes the controller .cfc and its spec; destroy view removes the views directory. They’re separate commands so you can scope each cleanup independently — e.g. destroy controller alone if you want to keep your views intact while rewriting the controller. The --force flag skips the interactive confirmation; without it, destroy prints Use --force to confirm deletion. and exits without touching anything. If you’d rather skip the CLI and remove the files yourself:
rm app/controllers/Posts.cfcrm -r app/views/postsLeave app/models/Post.cfc, app/migrator/migrations/<timestamp>_create_posts_table.cfc, app/db/seeds.cfm, and config/routes.cfm alone.
Generate the scaffold
Section titled “Generate the scaffold”wheels generate scaffold creates the controller, the seven CRUD views, and (if it didn’t already exist) the model and migration in one shot.
wheels generate scaffold Post title:string body:text status:enumThe controller
Section titled “The controller”-
Create
app/controllers/Posts.cfc:component extends="Controller" {function index() {posts = model("Post").findAll(order="publishedAt DESC");}function show() {post = params.post;}function new() {post = model("Post").new();}function create() {post = model("Post").new(params.post);if (post.save()) {redirectTo(route="post", key=post.id);} else {renderView(action="new");}}function edit() {post = params.post;}function update() {post = params.post;if (post.update(params.post)) {redirectTo(route="post", key=post.id);} else {renderView(action="edit");}}function delete() {post = params.post;post.delete();redirectTo(route="posts");}}
What’s going on:
params.postis populated by route model binding —resources("posts")asked Wheels to look up aPostbyparams.keybefore each key-scoped action. The action body uses it directly.createandupdatefollow the same pattern: try to persist, redirect on success, re-render the form on failure.renderView(action="new")re-uses thenewview without redirecting, sopostkeeps its user-typed values and validation errors.redirectTo(route="post", key=post.id)generates/posts/123. The singularpostroute comes for free from.resources("posts").deletecallspost.delete()then redirects back to the index.
The form partial
Section titled “The form partial”The new and edit views both render the same form. Put the shared markup in a partial.
-
Create
app/views/posts/_form.cfm:app/views/posts/_form.cfm <cfparam name="post" default=""><cfoutput>#errorMessagesFor("post")##startFormTag(action=IsNumeric(post.id ?: "") ? "update" : "create", key=post.id ?: "")##textField(objectName="post", property="title", label="Title")##textArea(objectName="post", property="body", label="Body")##select(objectName="post", property="status", options="draft,published,archived", label="Status")##dateField(objectName="post", property="publishedAt", label="Published at")#<button type="submit">Save</button>#endFormTag()#</cfoutput>
Notes:
- Partial filenames start with
_. You reference them as"form"(no underscore, no extension) when including. errorMessagesFor("post")renders a<div class="errors">with any validation errors on thepostobject. When there are none, it renders nothing.startFormTagpicks the right HTTP method and action URL based on whetherpost.idexists. A newPosthas no id, so the form posts tocreate; an existing one PATCHes toupdate.textField,textArea,select,dateFieldare object-bound helpers —objectName="post"plusproperty="title"becomesname="post[title]"with the current value pre-filled. Each helper emits its own<label>wrapper around the input; passlabel="Title"to set the visible text. Don’t wrap them in another<label>— that produces nested<label>elements, which browsers handle inconsistently.
The four views
Section titled “The four views”Each view is a thin wrapper. Compile checking here is skipped — these are plain HTML with a handful of helpers.
-
Create
app/views/posts/index.cfm:app/views/posts/index.cfm <cfparam name="posts" default=""><cfoutput><h1>Posts</h1><p>#linkTo(route="newPost", text="New post")#</p><cfloop query="posts"><article><h2>#linkTo(route="post", key=posts.id, text=posts.title)#</h2><p>#posts.body#</p><small>status: #posts.status#</small></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>status: #post.status#</p><p>#linkTo(route="editPost", key=post.id, text="Edit")# ·#buttonTo(route="post", key=post.id, text="Delete", method="delete")# ·#linkTo(route="posts", text="← all posts")#</p></cfoutput> -
Create
app/views/posts/new.cfm:app/views/posts/new.cfm <h1>New post</h1><cfoutput>#includePartial("form")#</cfoutput> -
Create
app/views/posts/edit.cfm:app/views/posts/edit.cfm <h1>Edit post</h1><cfoutput>#includePartial("form")#</cfoutput>
linkTo(route="newPost"), linkTo(route="editPost", key=...), and buttonTo(route="post", key=..., method="delete") all use route names that .resources("posts") generated. buttonTo emits a small form (because browsers don’t issue DELETE from links) rather than an anchor tag.
Update the routes
Section titled “Update the routes”Part 2 restricted the resource to only="index,show". Drop the restriction so all seven actions route.
-
Replace
config/routes.cfm:mapper().resources(name="posts", binding=true).get(name="hello", pattern="/hello", to="main##hello").wildcard().root(to="posts##index", method="get").end();
With .resources(name="posts", binding=true) alone, all seven REST routes activate. The named routes (post, posts, newPost, editPost) light up at the same time — that’s what the views already reference.
The binding=true arg turns on route model binding. When a request hits /posts/:key, Wheels looks up Post by that key BEFORE your controller action runs and hands it to you as params.post. That’s why show, edit, update, and delete can use params.post directly without calling findByKey themselves. Without binding=true, params.post is undefined on those actions — you’d have to look up the model manually.
Packages — the activation model
Section titled “Packages — the activation model”Core Wheels lives in vendor/wheels/. Optional features ship as separate packages — standalone repositories maintained under wheels-dev/ and indexed by the wheels-dev/wheels-packages registry. Installing one means getting its files into vendor/<name>/. On app boot, PackageLoader.cfc walks vendor/*/package.json and wires each package’s mixins, services, and middleware into the framework.
Six first-party packages ship today:
wheels-hotwire— Turbo Drive for SPA-like page transitions, Turbo Frames for partial updates, Turbo Streams for server-pushed DOM changes. No app code changes needed for Turbo Drive — activation alone makes link clicks feel instant.wheels-basecoat— shadcn/ui-quality UI components for forms, buttons, and layouts.wheels-sentry— error tracking integration.wheels-i18n— internationalization.wheels-seo-suite— SEO tooling.wheels-legacy-adapter— 3.x → 4.x compatibility shims.
Browse them all with wheels packages list. This tutorial uses the first two.
Conceptually, installing Hotwire looks like this:
gh repo clone wheels-dev/wheels-hotwire vendor/wheels-hotwirewheels reloadOnce Hotwire is active, clicking any internal link becomes a Turbo Drive visit — the page body swaps in without a full reload, and the browser history is preserved. The magic is invisible: no <turbo-frame> markup is required for the baseline experience.
Smoke test
Section titled “Smoke test”Confirm your CLI is the version this tutorial targets:
wheels --versionCheckpoint
Section titled “Checkpoint”Reload the app and exercise the full CRUD cycle:
wheels reloadcurl http://localhost:8080/posts— returns the index with the two seeded posts and a “New post” link.- Visit
http://localhost:8080/posts/1in a browser — shows one post, with Edit, Delete, and back buttons. - From the index, click “New post”, fill the form, submit — a third post appears on the index page.
Troubleshooting
Section titled “Troubleshooting”Route 'editPost' not found — you dropped only="index,show" from .resources("posts") but didn’t reload. Run wheels reload.
Component Posts has no public method create — the rewritten Posts.cfc wasn’t reloaded. Run wheels reload (or append ?reload=true&password=... to any URL).
params.post is undefined in show/edit/update/delete — route model binding didn’t resolve. Make sure .resources(name="posts") is present and that your URL includes a key segment (/posts/1, not /posts).
What’s next
Section titled “What’s next”In Part 4: Validations + Turbo Frames you’ll add validatesPresenceOf to the Post model, wrap the form in a <turbo-frame>, and watch inline validation errors update without a page reload.
The scaffold’s generated controller is a great starting point for Controllers and Actions. The _form.cfm it emits is unpacked in Views, Layouts, Partials.