Skip to content

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 scaffold produces — 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.key into params.post automatically
  • What packages are and how the vendor/ activation model works

Estimated time: 25 minutes.

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").

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.cfc with index, show, new, create, edit, update, delete
  • app/views/posts/index.cfm, show.cfm, new.cfm, edit.cfm
  • app/views/posts/_form.cfm — shared by new and edit

Because scaffolds follow convention, a plain .resources("posts") line in config/routes.cfm is all the routing you need. Wheels maps GET /postsindex, GET /posts/newnew, POST /postscreate, GET /posts/:keyshow, GET /posts/:key/editedit, PATCH /posts/:keyupdate, and DELETE /posts/:keydelete.

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.

Drop the hand-written controller and views. The model, migration, seed file, and the resources("posts") route stay.

your shell
wheels destroy Posts controller --force
wheels destroy Posts view --force

The 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:

your shell
rm app/controllers/Posts.cfc
rm -r app/views/posts

Leave app/models/Post.cfc, app/migrator/migrations/<timestamp>_create_posts_table.cfc, app/db/seeds.cfm, and config/routes.cfm alone.

wheels generate scaffold creates the controller, the seven CRUD views, and (if it didn’t already exist) the model and migration in one shot.

your shell
wheels generate scaffold Post title:string body:text status:enum
  1. 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.post is populated by route model binding — resources("posts") asked Wheels to look up a Post by params.key before each key-scoped action. The action body uses it directly.
  • create and update follow the same pattern: try to persist, redirect on success, re-render the form on failure. renderView(action="new") re-uses the new view without redirecting, so post keeps its user-typed values and validation errors.
  • redirectTo(route="post", key=post.id) generates /posts/123. The singular post route comes for free from .resources("posts").
  • delete calls post.delete() then redirects back to the index.

The new and edit views both render the same form. Put the shared markup in a partial.

  1. 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 the post object. When there are none, it renders nothing.
  • startFormTag picks the right HTTP method and action URL based on whether post.id exists. A new Post has no id, so the form posts to create; an existing one PATCHes to update.
  • textField, textArea, select, dateField are object-bound helpers — objectName="post" plus property="title" becomes name="post[title]" with the current value pre-filled. Each helper emits its own <label> wrapper around the input; pass label="Title" to set the visible text. Don’t wrap them in another <label> — that produces nested <label> elements, which browsers handle inconsistently.

Each view is a thin wrapper. Compile checking here is skipped — these are plain HTML with a handful of helpers.

  1. 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>
  2. 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>
  3. Create app/views/posts/new.cfm:

    app/views/posts/new.cfm
    <h1>New post</h1>
    <cfoutput>#includePartial("form")#</cfoutput>
  4. 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.

Part 2 restricted the resource to only="index,show". Drop the restriction so all seven actions route.

  1. 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.

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:

illustrative — do not type
gh repo clone wheels-dev/wheels-hotwire vendor/wheels-hotwire
wheels reload

Once 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.

Confirm your CLI is the version this tutorial targets:

Terminal window
wheels --version

Reload the app and exercise the full CRUD cycle:

your shell
wheels reload
  1. curl http://localhost:8080/posts — returns the index with the two seeded posts and a “New post” link.
  2. Visit http://localhost:8080/posts/1 in a browser — shows one post, with Edit, Delete, and back buttons.
  3. From the index, click “New post”, fill the form, submit — a third post appears on the index page.

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).

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.