Basics
Controllers and Actions
A controller is the CFC that runs after the router picks a route and before the view renders. Each public function is an action. This page shows you how to write one, how the seven REST actions fit together, how filters run around them, and how to render, redirect, and flash a message on the way out.
You’ll learn:
- How to write a controller with a single action and matching view
- How
resources()maps to seven REST actions and what each one typically does - How
config()registers filters and why filter methods must beprivate - Where URL segments and form data land inside
params - How to render a view, a partial, text, or JSON — and how to redirect with a flash message
Write your first controller
Section titled “Write your first controller”A controller lives in app/controllers/ and extends the framework’s Controller base class. The filename is plural PascalCase (Posts.cfc); the class name matches the file. Each public function is an action the router can dispatch to.
component extends="Controller" { function index() {}}After the action runs, Wheels automatically renders app/views/posts/index.cfm wrapped in app/views/layout.cfm. You don’t call render explicitly unless you want something other than the default.
The seven REST actions
Section titled “The seven REST actions”.resources(name="posts") in config/routes.cfm wires up seven routes. Each maps to an action you write on the controller. This is the convention the rest of the framework assumes — stick to it unless you have a reason not to.
| Action | HTTP method and path | What it does | Typical body |
|---|---|---|---|
index | GET /posts | List records | posts = model("Post").findAll() |
new | GET /posts/new | Render an empty form | post = model("Post").new() |
create | POST /posts | Persist a new record | model("Post").new(params.post).save() + redirect |
show | GET /posts/:key | Render one record | post = model("Post").findByKey(params.key) |
edit | GET /posts/:key/edit | Render an edit form | Same load as show |
update | PATCH /posts/:key | Persist changes to an existing record | post.update(params.post) + redirect |
delete | DELETE /posts/:key | Destroy a record | post.delete() + redirect |
A full scaffold-style controller wires all seven together. With binding=true on the resource route, the member actions see params.post already loaded as a model instance.
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"); }}config() and filters
Section titled “config() and filters”config() runs once per controller instantiation — it’s where you register filters, verify rules, and any other per-controller setup. Don’t put action logic here; it runs before any action is known.
filters(through="methodName") binds a private method to run before every action in this controller. Use only="..." to restrict it to specific actions or except="..." to skip them. Pass type="after" to run after the action instead of before.
component extends="Controller" { function config() { filters(through="authenticate", except="index,show"); } function index() {} function new() {} private function authenticate() { if (!StructKeyExists(session, "userId")) { redirectTo(route="login"); } }}Filter methods must be declared private. A public function on a controller is a routable action — if you forget the keyword on authenticate, the router will happily accept GET /posts/authenticate and call it directly, which is not what you want. The private keyword keeps it off the action surface. Framework helpers mixed onto every controller (env(), model(), redirectTo(), linkTo(), and their siblings) are automatically excluded from the routable surface — they resolve to a 404 regardless of how they are requested.
Params
Section titled “Params”The params struct is built by the framework on every request. It merges three sources into one flat struct:
- URL segments —
:keyfrom/posts/:keylands inparams.key. - Query string —
?page=2lands inparams.page. - Form fields —
<input name="post[title]">lands inparams.post.title.
component extends="Controller" { function show() { post = model("Post").findByKey(params.key); } function create() { post = model("Post").new(params.post); post.save(); } function index() { posts = model("Post").findAll(page=params.page, perPage=25); }}How params.post gets populated from <input name="post[firstName]"> is the form helper’s job — see Forms and Form Helpers.
Rendering
Section titled “Rendering”By default, when your action returns without calling a render function, Wheels renders app/views/<controllername>/<actionname>.cfm wrapped in the layout. Call one of the render functions below only when you want something different.
renderView — override the view or layout
Section titled “renderView — override the view or layout”Render a different action’s view (common when create fails and you want the new form back with errors), or drop the layout entirely.
component extends="Controller" { function create() { post = model("Post").new(params.post); if (post.save()) { redirectTo(route="post", key=post.id); } else { renderView(action="new"); } }}renderPartial — render just a partial
Section titled “renderPartial — render just a partial”The right choice when a Turbo Frame submits a form and you want to swap only the frame’s contents. The partial lives at app/views/<controller>/_<name>.cfm.
component extends="Controller" { function create() { post = model("Post").new(params.post); if (post.save()) { redirectTo(route="post", key=post.id); } else { renderPartial(partial="form", post=post, layout=false); } }}renderText — render a plain string
Section titled “renderText — render a plain string”For trivial responses: webhooks, health checks, debug endpoints.
component extends="Controller" { function ping() { renderText(text="ok"); }}renderWith — render JSON (or any format)
Section titled “renderWith — render JSON (or any format)”renderWith(data=...) serializes the data struct or query into the format the request asked for. A request with Accept: application/json (or a URL ending in .json) gets JSON back; add .xml and the same action serializes XML. This is the Wheels equivalent of renderJSON in other frameworks.
component extends="Controller" { function show() { post = model("Post").findByKey(params.key); renderWith(data=post); }}redirectTo
Section titled “redirectTo”redirectTo issues a cflocation redirect to a named route, a controller/action pair, or an absolute URL. The most common shape passes a route name and the key:
component extends="Controller" { function create() { post = model("Post").create(params.post); if (post.save()) { redirectTo(route="post", key=post.id, success="Post created"); } else { renderView(action="new"); } }}Any extra argument that isn’t a known redirectTo parameter (like success above) is set into the flash for the next request. redirectTo doesn’t return on its own — if more statements follow the redirect, add your own return. The common case where the redirect is the last statement just works.
Flash messages
Section titled “Flash messages”The flash is a struct that survives exactly one redirect. Write to it during one request, read from it on the next, and it clears automatically.
flashInsert(success="Post created")writes a key for the next requestflashKeyExists("success")checks if a key is present in the layout/viewflash("success")returns that key’s value (or the whole struct if no key given)
In practice most apps write via the redirectTo(..., success="...") shortcut shown above and read from a layout partial that loops over whatever keys it finds.
Strong params
Section titled “Strong params”Never blindly hand params.post to a model constructor when the form submits fields the user shouldn’t be allowed to set. A user-posted isAdmin=true or userId=42 would get mass-assigned through model("Post").new(params.post). The safe pattern is to name the attributes explicitly:
component extends="Controller" { function create() { post = model("Post").new(title=params.post.title, body=params.post.body); post.userId = session.userId; if (post.save()) { redirectTo(route="post", key=post.id); } else { renderView(action="new"); } }}Wheels 4.0 also ships an allowlist mechanism on the model itself for the cases where the explicit form gets tedious. The Security Hardening page has the deep dive; until then, the explicit-pick pattern above is what every controller in the tutorial uses.
Reserved scope names
Section titled “Reserved scope names”CFML reserves a set of identifiers as scope names. Declaring a local var with the same name causes scope-precedence collisions — the engine may resolve the identifier to the scope struct instead of the value you declared, silently producing wrong behavior or a runtime error.
Identifiers to avoid as local variables, var declarations, or function parameters:
client, url, form, session, cgi, request, application, cookie, server, arguments, variables, local, this
The client collision is the most confusing in practice. On Lucee 7, clientManagement is off by default. When you write client.someMethod() and client is also a local variable, Lucee resolves the identifier to the disabled client scope and throws:
lucee.runtime.exp.ExpressionException: client scope is not enabledThe error names the scope, not the variable — so the natural reading is “my app is misconfigured”, not “I chose a bad variable name”. The fix is a rename, not a config change.
function processOrder() { var client = paymentService(); // tempting name for a service handle var result = client.charge(amount); // → "client scope is not enabled"}function processOrder() { var gateway = paymentService(); var result = gateway.charge(amount);}url, form, and request are equally common as service-object handles (“the API request”, “the form parser”) and carry the same collision risk. Pick a domain-specific name instead.