CFML MVC framework with ActiveRecord ORM. The framework itself lives in vendor/wheels/ (NOT a dependency — this repo IS the framework). The repo also contains a demo app under app/ you can hand-test against.
vendor/wheels/ Framework core (model/, controller/, view/, dispatch/, migrator/, middleware/, …)
vendor/wheels/tests/specs/ Framework test suite — what CI runs across every engine × DB
app/ Demo app (models, controllers, views, migrations) — exercise framework changes here
tests/specs/ Demo-app test suite (separate from the framework suite)
cli/lucli/ The `wheels` binary — branded LuCLI runtime + Module.cfc (MCP tools)
cli/lucli/services/deploy/ `wheels deploy` (Kamal port — see .ai/wheels/deploy.md)
cli/lucli/tests/specs/ CLI test suite
config/settings.cfm Demo-app config (routes.cfm, environment.cfm, services.cfm-if-present)
plugins/ DEPRECATED — legacy plugin system; modern packages live in vendor/<name>/
.ai/wheels/ Deep reference docs Claude searches when needed
.claude/commands/ Wheels-bot prompts (.github/workflows/bot-*.yml runs these)
Branding: the project name is Wheels (not "CFWheels"). The rebrand happened at v3.0. Use "Wheels" in code, comments, commits, PRs, and docs.
| If you touched | Run | Required? |
|---|---|---|
vendor/wheels/** |
bash tools/test-local.sh (full) or bash tools/test-local.sh <area> |
Always |
app/** only |
Demo-app specs via wheels test |
Always |
cli/lucli/** |
bash tools/test-cli-local.sh |
Always |
Anything cross-engine-risky (closures, obj.map(), reserved scopes, struct literals, mixins) |
tools/test-matrix.sh adobe2023 mysql AND tools/test-matrix.sh lucee7 mysql |
If touched code matches any anti-pattern below |
| Added/changed a migration | wheels migrate latest && wheels migrate down && wheels migrate up |
Always |
| Changed a public framework API | grep -r callers under vendor/wheels, app, tests, cli/lucli/tests |
Always |
Type checks and a green test suite verify code correctness. They do NOT verify feature correctness for UI changes — if you changed a view/form/route, hand-test it in a browser or say so explicitly.
The framework must run on Lucee 5/6/7, Adobe CF 2018/2021/2023/2025, and BoxLang. These rules cause more bugs than anything else combined.
-
obj.map()resolves to the built-in struct member function on Lucee/Adobe — not your CFC method. UsemapInstance()on the Injector, or rename your method. -
applicationscope doesn't accept function members on Adobe CF. Pass a plain struct context instead. -
Closure
thiscaptures the declaring scope — usevar ctx = {ref: obj}to share references across closures. -
obj["key"]()inside closures crashes Adobe CF 2021/2023's parser. Split:var fn = obj["key"]; fn();. -
Inline closure as constructor named arg (
new Foo(callback = function(){...})) crashes Adobe CF withArrayStoreException: ASTcffunction. Worse: it takes down the entire TestBox bundle becausegetComponentMetadata()triggers eager compilation. Hoist:var fn = function(){...}; new Foo(callback = fn);. -
Adobe CF copies arrays by value in struct literals.
{arr = myArray}then mutatingarrinside a closure won't affect the original. Use parent struct ref:{owner = parentStruct}thenowner.arr. -
privatemixin functions are not integrated.$integrateComponents()only copiespublicmethods into model/controller objects. ALL helpers invendor/wheels/model/*.cfc, view helpers, etc. MUST usepublicaccess with$prefix for internal scope. BoxLang passes; Lucee/Adobe fail. -
Left(str, 0)crashes Lucee 7. Guard:len > 0 ? Left(str, len) : "". -
toBeInstanceOf("component")fails on BoxLang — returns the FQN, not the literal"component". UsetoBeWheelsModel()for finder results. -
Adobe CF 2023 and 2025 reject the
argumentsscope asattributeCollectionon any built-in CFML tag. Affects everycfheader/cfcache/cfcontent/cfmail/cfdirectory/cffile/cflocation/cfhtmlhead/cfimage/cfdbinfo/cfinvoke/cfwddx/cfzipwrapper. Covers both the string-interpolated (attributeCollection = "#arguments#") and direct-struct (attributeCollection = arguments) forms. Adobe 2023/2025 throw —cfheader's message is"Failed to add HTML header"; other tags surface their own — and$header()is catastrophic because it runs on every request. Copy to a plain struct first:local.args = {}; for (local.key in arguments) { local.args[local.key] = arguments[local.key]; }. Lucee 6/7, BoxLang, and Adobe 2018/2021 accept both forms; Adobe 2023/2025 require the plain struct. The 13 sites invendor/wheels/Global.cfcwere patched uniformly in #2750. -
Anything written through
local.insidecatchdoesn't persist on BoxLang. Catch body runs under a nestedlocalthat gets discarded on exit, soexpect(local.X)after the catch reads the un-touched outer value. Use a struct field:var state = {flag = false}; ... state.flag = true;. Barevar bareName+ unscopedbareName = truealso works but the struct form mirrorsTenantResolverSpecand is the prior-art pattern.The struct form only works if you access it WITHOUT the
local.prefix.local.state.flag = trueinside a catch fails exactly like a scalarlocal.X = ...— the nestedlocalshadowslocal.state, so the write lands on a discarded copy rather than mutating the outer struct. The prefix is what breaks it, not the assignment shape:var state = {type = ""}; // RIGHT try { ... } catch (any e) { state.type = e.type; } local.state = {type = ""}; // WRONG — silently empty after the catch try { ... } catch (any e) { local.state.type = e.type; }This matters because
local.-scoping spec variables is the house style everywhere else, so "tidying" a catch-using spec to match is an easy and invisible way to break it. Doing exactly that toJobClassRoundTripSpeccost two BoxLang failures on every database (Expected [Wheels.JobClassNotFound] but received []) — green on Lucee, caught only by the compat matrix. -
for (local.i = ...)insidefinallymiscompiles on Lucee 7. Lucee 7.0.1+100 throwsvariable [local] doesn't existat runtime when aforloop declares or iterateslocal-/var-scoped variables inside afinallyblock (one probe shape even produced a JVMExpecting a stackmap frameverifier error). Bare assignments and function calls infinallyare fine; loops are not. Hoist the loop into apublic$-prefixed helper and call it fromfinally— reference:$restoreEmailViewVariables()invendor/wheels/controller/miscellaneous.cfc(#2922). -
Bare tag-in-script statements without parentheses (e.g.
cfabort;) are Lucee-only. Adobe CF compiles the bare token as a reference to an undefined VARIABLE and throwsVariable CFABORT is undefinedat runtime (every Adobe engine, not just one release). Use the script keyword (abort;) or the parenthesized call form (cfheader(...)-style) instead. TheenablePublicComponent=false404 branch invendor/wheels/Dispatch.cfcshipped a barecfabort;, which turnedGET /on every stock Adobe install intesting/productioninto an HTTP 500 (#3029). Structural guard:vendor/wheels/tests/specs/security/BareCfabortGuardSpec.cfcfails the suite if any bare script-contextcfabortstatement reappears undervendor/wheels/**/*.cfc(tag-context<cfabort>in.cfm/tag-based CFCs stays legal). -
Adobe 2025's JVM rejects member calls on JDK-internal classes (JPMS). Calling any member on an object whose runtime class lives in an unexported package (
com.sun.*,jdk.internal.*) — e.g. thecom.sun.crypto.provider.PBKDF2KeyImplreturned bySecretKeyFactory.generateSecret()— throwsjava.lang.reflect.InaccessibleObjectExceptionon Adobe 2025 (its reflection layer bulk-setAccessibles the concrete class's methods; Lucee, BoxLang, and Adobe ≤2023 tolerate the same call, so local Adobe 2023 green does NOT cover this). Route the call through the exported interface'sMethodobject instead:CreateObject("java","java.lang.Class").forName("javax.crypto.SecretKey").getMethod("getEncoded", JavaCast("null","")).invoke(keyObj, JavaCast("null",""))—getMethod/invoketreat the null varargs as empty. Hit byPasswordHasher.$deriveKey()(#3300); watch for it with any Java factory API that returns internal implementation types. -
A parameter named
requestmakes the barerequesttoken resolve inconsistently on Adobe 2025. In a function declaring a parameter namedrequest, Adobe CF 2025 can resolve barerequestto the built-in scope in one expression position and toarguments.requestin another within the same function — so a guard written one way cannot protect an access written the other way.if (StructKeyExists(request, "wheels")) { StructDelete(request.wheels, "tenant"); }passed the guard and then threwElement WHEELS is undefined in REQUEST. UseIsDefined("request.wheels.tenant"), which string-resolves the whole dotted path in one evaluation, or assign before use (if (!StructKeyExists(request, "wheels")) { request.wheels = {}; }then write) — never mix the two forms. This hits every middleware component, becausewheels.middleware.MiddlewareInterfacemandates the signaturehandle(required struct request, required any next); anti-pattern 11's "never name a parameter after a reserved scope" is unavailable there. Lucee 6/7, BoxLang and Adobe 2023 all resolve consistently, so local Lucee green and Adobe 2023 smokes do NOT cover this — only the Adobe 2025 matrix legs catch it, andcompat-matrix.ymldoes not run on PRs (weekly cron +workflow_dispatch,continue-on-error: true). Hit byTenantResolver.handle()in #3338. -
Two receiver shapes break Adobe's parser at COMPILE time with the same
MissingNameException. Both throwcoldfusion.compiler.CFMLParserBase$MissingNameException: Invalid construct: Either argument or name is missing("When using named parameters to a function, each parameter must have a name"). Adobe appears to parse the construct as a script-style tag call and demand at least one attribute.16a — a parenthesized
newin receiver position, on EVERY Adobe engine.(new wheels.Job()).$someMethod(arg = "x")fails to compile on Adobe 2023 and 2025; Lucee 6/7 and BoxLang accept it. The argument list is irrelevant here — named arguments do not save it, because the receiver is what the parser chokes on. Hoist the instance to a variable first:// WRONG — zeroes out both Adobe legs revived = (new wheels.Job()).$instantiateJobClass(jobClass = persisted); // RIGHT — variable receiver; 22 spec files already do this and pass on Adobe var bridge = new wheels.Job(); revived = bridge.$instantiateJobClass(jobClass = persisted);
Note the
(new X()).method()form appears in this file's own Background Jobs examples and in user-facing docs — it is fine in application code that only ever runs on Lucee, and fatal in the core spec suite, which compiles on all five engines. Hit byJobClassRoundTripSpecin #3351.16b — a zero-argument call through the
applicationscope, Adobe 2025. Inside a closure,application.wo.$someMethod()with an empty argument list — used as a bare statement or as the whole right-hand side of an assignment — fails the same way. This is theapplication-scope sibling of invariant 2. Verified boundaries — each of these compiles, so do not "fix" them:- any argument at all:
application.wo.$get("showErrorInformation") - nested inside another call:
expect(application.wo.$statusCode()).toBe(418)(long-standing inrenderingSpec) - chained further:
application.wo.mapper().resources("posts")(RoutePrecedenceSpec) - a non-
applicationreceiver, zero args, bare statement in a closure:_controller.$clearCachableActions()(cachingSpec),strategy.logout()(SessionStrategySpec),local.c.$warnIfConfigSkipsSuper()(configSuperWarningSpec)
Two things make this expensive to diagnose. Adobe attributes the error to the enclosing
describe(...)line, not the offending statement, so it reads like a broken test-block signature. And because the core suite compiles viadirectory="wheels.tests.specs", one occurrence zeroes out the entire engine leg — adobe2025 reportstests="0"for every database while Lucee/BoxLang/Adobe 2023 stay green, andcompat-matrix.ymldoes not run on PRs. In test code, ensure request state inline (if (!StructKeyExists(request.wheels, "$pagination")) { request.wheels["$pagination"] = {} }) rather than calling a void$-helper throughapplication.wo; in framework code prefer helpers that return what they ensure, so callers writelocal.store = $ensurePaginationStore();. Hit by the #3339 pagination-namespace specs.Bisect this class of bug with a single probe against a running container instead of CI (~13s vs ~19min):
curl -s "http://localhost:62025/wheels/core/tests?db=sqlite&format=json&cli=true" | \ python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('totalPass','COMPILE FAIL'), d.get('RootCause',{}).get('snippet',''))"
- any argument at all:
-
A parameter named
defaultloses its name — and its declared default value — if a type keyword precedes it, on every Adobe engine. Adobe treatsdefaultas reserved in a parameter position, sostring default = ""registers an argument namedstringand discardsdefaultentirely; the declared default value never materializes in theargumentsscope. Dropping the type declaration fixes it —default = ""(untyped) parses correctly on Adobe, Lucee 6/7 and BoxLang alike.// WRONG — arguments scope gets a key named STRING; `default` never appears public any function float(string columnNames, string default = "", boolean allowNull = "true") { // RIGHT — arguments.default exists and carries "" public any function float(string columnNames, default = "", boolean allowNull = "true") {Explicitly-passed values still arrive (as a separate lowercase
defaultkey alongside the bogusSTRINGone), which is what makes this so quiet: every call site that passesdefault=works, and only the declared default silently vanishes.TableDefinition.uniqueidentifier()shippedstring default = "newid()"and emitted DDL with noDEFAULTclause on Adobe for as long as it has existed. All 24defaultparameter declarations undervendor/wheels/were untyped uniformly in the #3302 burn-down;cli/lucli/services/ArgSpec.cfcstill has typed ones but runs on the Lucee-only LuCLI runtime. -
Adobe 2025's
FileWrite()appends a trailing0x0Awhen handed a simple value.FileWrite(path, "hello world")puts 12 bytes on disk, not 11. Lucee 6/7, BoxLang and Adobe 2023 write the string verbatim, so local Lucee green does not cover this. Harmless for generated source or JSON; fatal anywhere the read must round-trip what was written, which is why it corrupted every object stored throughwheels.storage.drivers.LocalDisk(#3302). Decode to binary first — the binary overload has no line-ending behaviour on any engine:var payload = IsBinary(content) ? content : CharsetDecode(content, "utf-8"); FileWrite(path, payload);
Verify Adobe CF fixes locally before pushing — don't iterate via CI:
curl -s "http://localhost:62023/wheels/core/tests?db=mysql&format=json" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('totalPass',0),'pass',d.get('totalFail',0),'fail',d.get('totalError',0),'error')"Adobe serves cached compiled classes — ?reload=true does NOT pick up an edited .cfc. ?reload=true rebuilds the Wheels application scope, not Adobe's template cache, so a source change can keep producing the old result for many minutes. This reads exactly like a fix that did not work, and the natural response — reverting or piling on a second change — makes it worse. After editing framework source, docker restart wheels-adobe2023-1 (or -adobe2025-1) before trusting any Adobe result. Lucee and BoxLang pick edits up from the bind mount immediately; only the Adobe legs need this.
Narrow the run with directory= — it turns a ~19-minute CI round-trip into ~5 seconds. The core-test endpoint accepts a dotted TestBox scope, allowlisted to wheels.tests.* and vendor.<package>.tests.*. bundles= is silently ignored (#3352), so directory= is the only working filter. Point it at a directory, never a single spec file — a single-file scope discovers 0 bundles and reports green (#3083); check bundlesDiscovered in the payload.
curl -s "http://localhost:62025/wheels/core/tests?db=sqlite&directory=wheels.tests.specs.security&format=json&reload=true"Deep reference: .ai/wheels/cross-engine-compatibility.md.
These are the most common mistakes when generating or modifying Wheels code. Check every time.
Wheels functions cannot mix positional and named arguments. #1 error source.
// WRONG — mixed positional + named
hasMany("comments", dependent="delete");
validatesPresenceOf("name", message="Required");
// RIGHT — all named when using options
hasMany(name="comments", dependent="delete");
validatesPresenceOf(properties="name", message="Required");
// RIGHT — positional only (no options)
hasMany("comments");
validatesPresenceOf("name");Model finders return query objects, not arrays. Loop accordingly.
// WRONG
<cfloop array="#users#" index="user">
// RIGHT
<cfloop query="users">
#users.firstName#
</cfloop>// WRONG — Rails-style inline (not supported)
.resources("posts", function(r) { r.resources("comments"); })
// RIGHT — callback syntax (recommended)
.resources(name="posts", callback=function(map) {
map.resources("comments");
})
// RIGHT — manual nested=true + end()
.resources(name="posts", nested=true)
.resources("comments")
.end()scope(), namespace(), package(), and controller() also accept callback= and auto-close the scope when the callback returns — use the same callback form for these too (#3072).
#emailField(objectName="user", property="email")#
#urlField(objectName="user", property="website")#
#numberField(objectName="product", property="quantity", min="1", max="100")#
#telField(objectName="user", property="phone")#
#dateField(objectName="event", property="startDate")#
#colorField(objectName="theme", property="primaryColor")#
#rangeField(objectName="settings", property="volume", min="0", max="100")#
#searchField(objectName="search", property="query")#
// Tag forms: emailFieldTag, numberFieldTag, etc.execute() accepts only a SQL string — there is no parameters argument (Migration.cfc: execute(required string sql)). Use inline SQL.
// WRONG
execute(sql="INSERT INTO roles (name) VALUES (?)", parameters=[{value="admin"}]);
// RIGHT — and use CURRENT_TIMESTAMP for database-agnostic dates (MySQL/PG/MSSQL/H2/SQLite).
// NOW() fails on SQLite (the `wheels new` default DB) and SQL Server; no adapter rewrites it.
execute("INSERT INTO roles (name, createdAt, updatedAt) VALUES ('admin', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)");Routes match first-to-last. Wrong order = wrong matches.
Order: MCP routes → resources → custom named routes → root → wildcard (last!)
One blessed exception (#3073): placeholder-free patterns live in an exact-path index resolved BEFORE the ordered scan, so a literal like /posts/featured beats /posts/[key] regardless of declaration position. Declaration order still decides placeholder-vs-placeholder conflicts and ties between identical static patterns. Pinned by vendor/wheels/tests/specs/dispatch/RoutePrecedenceSpec.cfc; fast path in Dispatch.cfc::$findMatchingRoute, index built in Mapper.cfc.
createdAt, updatedAt, AND deletedAt (soft-delete marker). Don't add separate datetime columns for these. Verified against vendor/wheels/migrator/TableDefinition.cfc.
Public filter functions become routable actions.
// WRONG
function authenticate() { ... }
// RIGHT
private function authenticate() { ... }Conversely, public framework helpers mixed onto every controller (env, model, redirectTo, linkTo, the is* request predicates, the flash helpers, …) are auto-excluded from the routable surface. At app start application.wheels.protectedControllerMethods is built from the wheels.Global + wheels.controller.* + wheels.view.* mixin surface (the same getMetaData().functions set $integrateComponents mixes in), and $callAction() throws Wheels.ActionNotAllowed for any action whose name matches one — intended to fall through to the 404 path, but it currently surfaces as HTTP 500 in every environment (#3075). So a helper can't be invoked as an action — but you also can't name a user action after a framework helper (it errors instead of dispatching). The standard REST action names (index, show, new, edit, create, update, delete) are not helpers, so they're unaffected (#2845).
Every variable passed from controller to view needs a cfparam at the top of the view file.
<cfparam name="users" default="">
<cfparam name="user" default="">CFML closures can't access outer local vars. Use shared structs:
// WRONG
var count = 0;
items.each(function(i) { count++; }); // local.count not visible
// RIGHT
var result = {count: 0};
items.each(function(i) { result.count++; });Source: #2591 — consoleExec(url, body) received the URL scope struct in place of the URL string, throwing Cannot cast Object type [url] to a value of type [string].
Reserved scope names in CFML: url, form, cgi, client, session, application, cookie, request, server, arguments, variables, local, this. Naming a function parameter, local var, or argument the same as a scope shadows it but the scope can also win depending on engine and context.
// WRONG
function consoleExec(required string url, required string body) {
makeHttpPost(url, body); // url = URL scope struct on Lucee, not the string
}
// RIGHT
function consoleExec(required string requestUrl, required string body) {
makeHttpPost(requestUrl, body);
}Rule: never use a reserved scope name as a parameter, local var, or function argument name. Also avoid client in browser-test code (Lucee throws "client scope is not enabled" when accessed).
Source: #2736 — whereIn("id", []) previously emitted literal WHERE id IN (), a JDBC syntax error on every supported engine.
// As of 4.0.x — short-circuits to 1=0 (no rows) for IN, 1=1 (all rows) for NOT IN
model("Post").whereIn("id", []).count() // 0
model("Post").whereNotIn("id", []).count() // total count
model("Post").where("status","active").whereIn("id", []).count() // 0 (composes)When writing query-builder methods or anything that interpolates arrays into SQL IN/NOT IN: always handle empty inputs explicitly. Empty inputs aren't exotic — they're what you get from form filters, sub-query results, and any runtime-built array.
Source: #2725 — Cors middleware was echoing the comma-delimited allowOrigins config straight into Access-Control-Allow-Origin, violating the CORS spec (must be a single origin or *) and poisoning CDN caches.
When config accepts a list-shape (comma-delimited string or array) but the output is a single-value protocol field, you MUST resolve to one value (or omit the header). Don't pass the list through.
// WRONG
header("Access-Control-Allow-Origin", listed); // "https://a.com,https://b.com"
// RIGHT — match against request origin, emit single value or omit
var resolved = $resolveAllowOrigin(allowOrigins, requestOrigin); // "" | "*" | "https://a.com"
if (len(resolved)) header("Access-Control-Allow-Origin", resolved);Pair with Vary: Origin whenever the response varies by request origin (#2724).
Running set(allowCorsRequests=true) alongside a wheels.middleware.Cors instance no longer duplicates headers — the global path defers to the middleware automatically (#3114) and writes a one-time wheels.log warning. Remove the six allowCorsRequests / accessControlAllow* settings from config/settings.cfm once the middleware is configured.
Source: #2595 — wheels validate checked for extends="Model" with raw findNoCase() and was satisfied by a commented-out // component extends="Model" line, missing real missing-inheritance bugs.
Any validator, analyzer, scanner, or upgrade-check that does substring-matching over CFML source must strip line comments (// …), block comments (/* … */), AND tag comments (<!--- … --->) first. Helpers exist:
cli/lucli/services/Analysis.cfc::$stripCfmlComments()cli/lucli/Module.cfc::stripCfmlComments()cli/lucli/services/Doctor.cfc::$stripCfmlBlockComments()
Source: #2781 (t.references()) + #2803 (t.primaryKey()) — these two helpers were the last outliers in TableDefinition.cfc. Every sibling helper accepted columnNames / columnName via $combineArguments, but references required referenceNames and primaryKey required name. AI agents and humans both kept reaching for the consistent form and hitting "argument required" errors. Now resolved: both accept columnNames as an alias, and that's the preferred form going forward.
// RIGHT — modern, matches every other column helper
t.string(columnNames="name");
t.integer(columnNames="age");
t.references(columnNames="user");
t.primaryKey(columnNames="userId", autoIncrement=true);
// LEGACY — still works, but the new code path uses columnNames
t.references(referenceNames="user");
t.primaryKey(name="userId", autoIncrement=true);For new migrator helpers or anywhere you accept a column-name argument: declare string columnNames (NOT required), and call $combineArguments(args=arguments, combine="columnNames,columnName", required=true) at the top of the body. The pattern is documented in vendor/wheels/migrator/CLAUDE.md. Boolean nullable flag is allowNull everywhere — never null.
t.references() also respects useUnderscoreReferenceColumns (boolean, framework default false, wheels new template default true) — when true it produces <name>_id / <name>_type columns instead of <name>id / <name>type.
Association foreign-key defaults resolve either convention: the default derivation checks which column actually exists on whichever side owns the foreign key, rather than reading the setting (#3337 — before that fix the model layer derived <modelName><key> unconditionally and a stock wheels new app threw key [<name>id] doesn't exist on any include=). It is schema-driven on purpose: the migrator reads the flag per call, but the model-side default is memoized for the application lifetime, so honouring the flag there would let a runtime flip change migrations without changing models. Apps holding a mix of both shapes work for the same reason.
Polymorphic associations are not covered. belongsTo(polymorphic=true) and hasMany/hasOne with as= fix their foreign key to <name>id at registration time (vendor/wheels/model/associations.cfc:30, :81, :134), before the schema is available, so the join-time resolution never sees a blank to fill. Against an underscore-shaped schema those still need an explicit foreignKey="<name>_id".
- config(): All model associations/validations/callbacks and controller filters/verifies go in
config(). - Naming: Models singular PascalCase (
User.cfc), controllers plural PascalCase (Users.cfc), tables plural lowercase (users). - Parameters:
params.keyfor URL key,params.userfor form struct,params.user.firstNamefor nested. - extends: Models extend
"Model", controllers extend"Controller", tests extend"wheels.WheelsTest". (Legacy:"wheels.Test"was RocketUnit — never use for new tests.) - Validation property param:
property(singular) for single,properties(plural) for list:validatesPresenceOf(properties="name,email").
component extends="Model" {
function config() {
// Table/key (only if non-conventional)
table("tbl_users"); // setter is table(); tableName() is a getter — tableName("x") throws Wheels.InvalidArgument in dev/testing, no-op in production (#3079)
setPrimaryKey("userId");
// Associations — all named params when using options
hasMany(name="orders", dependent="delete");
belongsTo(name="role");
// Validations
validatesPresenceOf("firstName,lastName,email");
validatesUniquenessOf(property="email");
validatesFormatOf(property="email", regEx="^[\w\.-]+@[\w\.-]+\.\w+$");
// Callbacks
beforeSave("sanitizeInput");
// Calculated SQL properties — select=false keeps them off the default SELECT (hot path)
property(name="fullName", sql="firstName || ' ' || lastName", select=false);
// Query scopes — reusable, composable query fragments
scope(name="active", where="status = 'active'");
scope(name="recent", order="createdAt DESC");
scope(name="byRole", handler="scopeByRole"); // dynamic scope
// Enums — named values with auto-generated checkers and scopes
enum(property="status", values="draft,published,archived");
enum(property="priority", values={low: 0, medium: 1, high: 2});
}
private struct function scopeByRole(required string role) {
return {where: "role = '#arguments.role#'"};
}
}Finders: model("User").findAll(), findOne(where="..."), findByKey(params.key).
Create: model("User").new(params.user).save(), or model("User").create(params.user).
Include associations: findAll(include="role,orders"). Pagination: findAll(page=params.page, perPage=25).
Opt a select=false calculated property into one call (additive): findAll(includeCalculated="fullName"). Unknown names throw Wheels.CalculatedPropertyNotFound in dev/testing.
// Scopes — chain composably
model("User").active().recent().findAll();
model("User").byRole("admin").findAll(page=1, perPage=25);
// Enums — auto-generated checkers and scopes
user.isDraft(); // true/false
model("User").draft().findAll();
// Chainable query builder (injection-safe; values auto-quoted)
model("User")
.where("status", "active")
.where("age", ">", 18)
.whereNotNull("emailVerifiedAt")
.orderBy("name", "ASC")
.limit(25)
.get();
// Methods: where, orWhere, whereNull, whereNotNull, whereBetween, whereIn, whereNotIn, orderBy, limit, get
// Batch processing — memory-efficient
model("User").findEach(batchSize=1000, callback=function(user) {
user.sendReminderEmail();
});
model("User").findInBatches(batchSize=500, callback=function(users) {
processUserBatch(users);
});mapper()
.resources("users")
.resources("products", except="delete")
.resources(name="posts", callback=function(map) {
map.resources("comments");
})
.get(name="login", to="sessions##new")
.post(name="authenticate", to="sessions##create")
.root(to="home##index", method="get")
.wildcard() // keep last!
.end();Helpers: linkTo(route="user", key=user.id), urlFor(route="users"), redirectTo(route="user", key=user.id), startFormTag(route="user", method="put", key=user.id).
Resolves params.key into a model instance before the action runs. Lands in params.<singularModelName>. Throws Wheels.RecordNotFound (404) if missing; silently skips if the model class doesn't exist.
.resources(name="users", binding=true) // params.user
.resources(name="posts", binding="BlogPost") // params.blogPost
.scope(path="/api", binding=true, callback=function(map) { // all nested resources bound
map.resources("users");
})
set(routeModelBinding=true); // global, in config/settings.cfmRequires a paginated query: findAll(page=params.page, perPage=25). Recommended all-in-one helper: paginationNav().
// All-in-one nav
#paginationNav()#
#paginationNav(showInfo=true, showFirst="never", showLast="never", navClass="my-pagination")#
#paginationNav(windowSize=3)#
// Declarative presets — Bootstrap 4/5 and Tailwind
#paginationNav(viewStyle="bootstrap5")#
#paginationNav(viewStyle="bootstrap4")#
#paginationNav(viewStyle="tailwind")#
// Manual composition (like-for-like swap for legacy paginationLinks)
#paginationNav(
navClass="",
prepend='<ul class="pagination">',
append="</ul>",
prependToPage='<li class="page-item">',
appendToPage="</li>",
class="page-link",
classForCurrent="active",
addActiveClassToPrependedParent=true
)#
// Individual helpers
#paginationInfo()# #firstPageLink()# #previousPageLink()#
#pageNumberLinks()# #nextPageLink()# #lastPageLink()#showFirst / showLast / showPrevious / showNext accept "auto" (default), "always", or "never". Under "auto" the first/last anchors are hidden when the window already reaches the boundary; previous/next render disabled <span> at boundaries to preserve position. Booleans coerce (true→"always", false→"never").
viewStyle accepts "plain" (default), "bootstrap5", "bootstrap4", "tailwind". Bootstrap presets emit <li class="page-item active" aria-current="page"><span class="page-link">N</span></li>. Non-plain presets ignore manual-composition args.
In development, paginationNav() throws Wheels.PaginationNav.InvalidArgument for unknown sub-helper args. windowSize is consumed by paginationNav itself (not forwarded). Accepted pass-through: format, text, name, class, disabledClass, showDisabled, pageNumberAsParam, classForCurrent, linkToCurrentPage, prependToPage, appendToPage, addActiveClassToPrependedParent, route, controller, action, key, anchor, onlyPath, host, protocol, port, params. Named route segment variables are auto-exempted from the check.
Middleware runs at the dispatch level, before controller instantiation. Each implements handle(request, next).
// config/settings.cfm — global middleware
set(middleware = [
new wheels.middleware.RequestId(),
new wheels.middleware.SecurityHeaders(),
new wheels.middleware.Cors(allowOrigins="https://myapp.com")
]);
// config/routes.cfm — route-scoped
mapper()
.scope(path="/api", middleware=["app.middleware.ApiAuth"], callback=function(map) {
map.resources("users");
})
.end();Built-in: wheels.middleware.RequestId, wheels.middleware.Cors, wheels.middleware.SecurityHeaders, wheels.middleware.RateLimiter. Custom: implement wheels.middleware.MiddlewareInterface, place in app/middleware/.
Singleton lifecycle contract: both global and route-scoped middleware (including string-path entries) are resolved once and cached for the application lifetime. The same instance handles every matching request — stateful middleware (e.g. in-memory RateLimiter on a .scope()) accumulates state across requests as intended. Implication: every middleware component must be safe to share across concurrent requests (use CFML locks for any mutable state).
new wheels.middleware.RateLimiter() // fixed window, 60 req / 60s
new wheels.middleware.RateLimiter(maxRequests=100, windowSeconds=120, strategy="slidingWindow")
new wheels.middleware.RateLimiter(maxRequests=50, windowSeconds=60, strategy="tokenBucket")
new wheels.middleware.RateLimiter(storage="database") // auto-creates wheels_rate_limits
// rate-limit per API key — hoist the closure first: an inline function literal
// as a constructor named arg crashes Adobe CF (Cross-Engine Invariant 5)
var apiKeyFn = function(req) {
var apiKey = req.cgi.http_x_api_key ?: "";
return Len(apiKey) ? apiKey : "anonymous";
};
new wheels.middleware.RateLimiter(keyFunction=apiKeyFn)The keyFunction receives the dispatch middleware context {params, route, pathInfo, method, cgi}. The cgi member is the sanitized request.cgi copy overlaid on every inbound HTTP header under its CGI-style http_* name (built by Dispatch.$buildMiddlewareCgiScope()), so arbitrary headers like X-Api-Key resolve per client (#3074 — before 4.0.4 the context had no cgi key and req.cgi.* silently collapsed every client into one bucket). Keep the Len() guard: an empty-valued header reads as empty string, and on pre-fix versions a missing header does too.
Strategies: fixedWindow (default), slidingWindow, tokenBucket. Storage: memory or database. Emits X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. Returns 429 with Retry-After when exceeded.
windowSeconds must be > 0; maxRequests must be >= 0. Invalid values throw Wheels.RateLimiter.InvalidConfiguration at construction. maxRequests = 0 is a valid kill-switch.
Register services in config/services.cfm (loaded at app start; environment overrides supported):
local.di = injector();
local.di.map("emailService").to("app.lib.EmailService").asSingleton();
local.di.map("currentUser").to("app.lib.CurrentUserResolver").asRequestScoped();
local.di.bind("INotifier").to("app.lib.SlackNotifier").asSingleton();Resolve with service("emailService") anywhere, or inject("emailService, currentUser") in controller config(). Scopes: transient (default), .asSingleton(), .asRequestScoped(). Auto-wiring: init() params matching registered names are auto-resolved when no initArguments passed.
Optional first-party modules distributed as standalone repos and installed into vendor/<name>/. Auto-discovered from vendor/*/package.json on startup via PackageLoader.cfc with per-package error isolation.
vendor/ # Runtime: framework core + installed packages
wheels/ # Framework core (excluded from package discovery)
wheels-sentry/ # Installed package
plugins/ # DEPRECATED: legacy plugins still work with warning
First-party packages live in standalone repos under wheels-dev/, indexed by wheels-dev/wheels-packages:
wheels-sentry— error trackingwheels-hotwire— Turbo/Stimuluswheels-basecoat— UI componentswheels-legacy-adapter— 3.x → 4.x compatibility shimswheels-i18n— internationalizationwheels-seo-suite— SEO tooling
{
"name": "wheels-sentry",
"version": "1.0.0",
"wheelsVersion": ">=3.0",
"mappings": {"plugins.sentry": "."},
"provides": {"mixins": "controller", "services": [], "middleware": []},
"requires": {}, "replaces": {}, "suggests": {}
}mapping(singular): CFML-identifier-safe alias registered as a CFML mapping. Defaults to lower-camel-case ofname. Lets package CFCs usenew wheelsSentry.SentryClient().mappings(plural): struct of dotted aliases beyond the singular. Use for legacy compatibility paths (e.g.,plugins.sentrykeeps old call sites resolving). See #2705.provides.mixins: comma-delimited fromapplication,dispatch,controller,mapper,model,base,sqlserver,mysql,postgresql,h2,test, plusglobalornone. Defaultnone. View helpers belong incontrollermixins (views execute in controller'svariablesscope).requires/replaces/suggests: package name → semver constraint. Loader uses these, NOT legacydependencies.
wheels packages list # browse registry
wheels packages search <query>
wheels packages show <name>
wheels packages add <name> # latest compat version (canonical verb)
wheels packages add <name>@<ver> # pin
wheels packages add <name> --force # overwrite existing
wheels packages update <name> --yes
wheels packages update --all --yes
wheels packages remove <name>
wheels packages registry info # registry source + cache age
wheels packages registry refresh # bust 24h cacheOverride registry with WHEELS_PACKAGES_REGISTRY=<org>/<repo> (default wheels-dev/wheels-packages). Restart or wheels reload after install. Each package loads in its own try/catch — a broken one is logged and skipped.
All new tests use WheelsTest BDD syntax. RocketUnit (test_ prefix, assert()) is legacy only.
// vendor/wheels/tests/specs/model/MyFeatureSpec.cfc (framework) or tests/specs/...(app)
component extends="wheels.WheelsTest" {
function run() {
describe("My Feature", () => {
it("validates presence of name", () => {
var user = model("User").new();
expect(user.valid()).toBeFalse();
});
});
}
}- App tests:
/wheels/app/tests— project-specific, intests/specs/. Usestests/populate.cfmandtests/TestRunner.cfc. - Core tests:
/wheels/core/tests— framework, invendor/wheels/tests/specs/. Usesvendor/wheels/tests/populate.cfm. This is what CI runs across all engines × DBs.
Critical: core tests use directory="wheels.tests.specs" which compiles EVERY CFC in the directory. One compilation error in any spec file crashes the entire suite for that engine. The "inline closure as constructor named arg" anti-pattern (#5 in Cross-Engine Invariants) is the classic example.
- Test infra scope: Wheels internals (
$dbinfo,model(), etc.) aren't available as bare calls in.cfmfiles included from plain CFCs likeTestRunner.cfc. Useapplication.wo.model()or native CFML tags (cfdbinfo). #escape: HTML entities likeocontain#which CFML interprets as expression delimiter. In string literals, escape:&##111;. Comments (//) are fine. Unescaped#in strings crashes the entire test suite, not just that file.$clearRoutes()in test specs: NOT inherited fromwheels.WheelsTest. Copy fromlinksSpec.cfcif your spec manipulates routes.
bash tools/test-local.sh # all core tests (SQLite)
bash tools/test-local.sh model # vendor/wheels/tests/specs/model/
bash tools/test-local.sh controller # …/controller/
bash tools/test-local.sh view # …/view/
bash tools/test-local.sh security # …/security/
bash tools/test-local.sh middleware # …/middleware/
bash tools/test-local.sh dispatch # …/dispatch/
bash tools/test-local.sh migrator # …/migrator/
# Cross-engine via Docker (mirrors compat-matrix.yml exactly)
tools/test-matrix.sh # Lucee 7 + SQLite (fastest)
tools/test-matrix.sh lucee7 mysql
tools/test-matrix.sh lucee7 sqlite,mysql
tools/test-matrix.sh lucee6,lucee7 sqlite
tools/test-matrix.sh --all # full matrix
tools/test-matrix.sh --rebuild lucee7 # force image rebuild
tools/test-matrix.sh --down # teardownEngines: lucee6, lucee7, adobe2023, adobe2025, boxlang (CI matrix). Ports: 60006 / 60007 / 62023 / 62025 / 60001. Databases: sqlite, h2 (Lucee only), mysql, postgres, sqlserver, cockroachdb, oracle. Oracle is soft-fail in CI (see SOFT_FAIL_DBS in .github/workflows/compat-matrix.yml).
Java 21 + Wheels CLI 4.0.0+ required for tools/test-local.sh. Docker required for tools/test-matrix.sh. compose.yml bind-mounts source at ./:/wheels-test-suite so edit-reload-test cycles don't require image rebuilds.
tools/test-onboarding.sh simulates a brand-new-user fresh-install flow without touching your daily wheels install. Use when fixing CLI/framework/template code that affects wheels new → wheels start → wheels migrate latest. Validates cliff fixes BEFORE asking for a fresh-VM tutorial run. ~90s end-to-end across 7 phases. Deep reference: .ai/wheels/testing/onboarding-harness.md.
Specs extend wheels.wheelstest.BrowserTest. Install Playwright once: wheels browser setup (~370MB). Then bash tools/test-local.sh includes them. Deep reference: .ai/wheels/testing/browser-testing.md.
wheels_migrator_versions can drift from on-disk files when several developers share a single dev database (peer applied a migration whose file isn't yet in your branch). Detected and surfaced automatically; reconciliation is explicit:
wheels migrate latest— when a peer's tracked version sits above your latest local file, it now applies pending local migrations with a warning instead of silently no-op'ing on a "down" branch.wheels migrate info— orphan rows render as[?] <version> <name> (applied <timestamp>)when the enrichedwheels_migrator_versions.name/.applied_atcolumns are populated, or[?] <version> ********** NO FILE **********(Rails-style) for legacy rows.wheels migrate doctor— single-command health report. Lists orphans + pending; pure read.wheels migrate forget <version> --yes— delete a stale tracking row (refuses if a matching local file exists, refuses if version not in table).wheels migrate pretend <version> --yes— record a version as applied without runningup()(refuses if already applied or no matching file).
Tracking-table schema: wheels_migrator_versions(version, core_level, name, applied_at). The name and applied_at columns are additive (NULL for legacy rows) and added automatically via $ensureTrackingColumns() on first migrator call after upgrade. Both columns are populated by $setVersionAsMigrated(version, migrationName) going forward; existing rows stay NULL and display version-only.
Both forget and pretend are dry-run by default; --yes is required to mutate. Helpers live on Migrator.cfc: $getOrphanVersions(), $getOrphanVersionsWithMeta(), doctor(), forgetVersion(), pretendVersion(), $buildInfoOutput(), $ensureTrackingColumns(). Deep reference: .ai/wheels/troubleshooting/shared-dev-databases.md. User-facing guide: web/sites/guides/src/content/docs/v4-0-0/basics/shared-development-databases.mdx. Shipped across #2798, #2799, and the schema enrichment PR.
Generate migrations from model/DB schema diffs. Rename detection via explicit hints (authoritative) + heuristic suggestions (normalized-token + Levenshtein).
var am = CreateObject("component", "wheels.migrator.AutoMigrator");
var d = am.diff("User");
var d = am.diff("User", {renames: {"full_name": "fullName"}});
var d = am.diff("User", {heuristicThreshold: 0.85});
var all = am.diffAll({hints: {"User": {renames: {"full_name": "fullName"}}}, heuristicThreshold: 0.7});
am.writeMigration(d, "rename_name_field");Auto-migration is currently CFC-only (wheels.migrator.AutoMigrator, shown above). There is no wheels dbmigrate diff CLI command — invoking it errors.
Result struct: {modelName, tableName, addColumns, removeColumns, changeColumns, renameColumns, suggestedRenames}. Limits: PK renames not detected; rename + type change requires separate migrations; calculated properties excluded.
Convention-based, idempotent, CLI-supported.
// app/db/seeds.cfm — shared (all environments)
seedOnce(modelName="Role", uniqueProperties="name", properties={
name: "admin", description: "Administrator"
});
// app/db/seeds/development.cfm — dev-only (runs after seeds.cfm)
seedOnce(modelName="User", uniqueProperties="email", properties={
firstName: "Dev", lastName: "User", email: "dev@example.com"
});wheels seed # auto-detect env (canonical)
wheels seed --environment=production
wheels seed --generate # legacy: random test dataTo scaffold seed templates, use: wheels generate snippets seed-data (writes app/snippets/seeds*.cfm — copy or move to app/db/ to activate them). There is no wheels generate seed generator.
seedOnce(): idempotent — checks uniqueProperties via findOne(), creates only if not found. Execution: seeds.cfm → seeds/<environment>.cfm, wrapped in a transaction. Programmatic: application.wheels.seeder.runSeeds(). (Note: wheels db:seed is NOT a valid command — it errors. Use wheels seed.)
// app/jobs/SendWelcomeEmailJob.cfc
component extends="wheels.Job" {
function config() {
super.config();
this.queue = "mailers";
this.maxRetries = 5;
}
public void function perform(struct data = {}) {
sendEmail(to=data.email, subject="Welcome!", from="app@example.com");
}
}
// Enqueue
job = new app.jobs.SendWelcomeEmailJob();
job.enqueue(data={email: user.email});
job.enqueueIn(seconds=300, data={email: "..."});
job.enqueueAt(runAt=scheduledDate, data={});
// Process
result = (new wheels.Job()).processQueue(queue="mailers", limit=10);
stats = (new wheels.Job()).queueStats();Worker CLI (cli/lucli/Module.cfc::jobs() — thin wrapper over the jobsProcessNext/jobsStatus bridge commands in vendor/wheels/public/views/cli.cfm; requires a running server):
wheels jobs work --queue=mailers --interval=3 # long-lived worker loop; --max-jobs=N for one-shot batches, --quiet
wheels jobs status [--queue=mailers] [--format=json]The retry/purge/monitor verbs are tracked follow-ups (#3090) — invoking one errors with the programmatic equivalent ((new wheels.Job()).retryFailed() / .purgeCompleted()).
Backoff: this.baseDelay = 2, this.maxDelay = 3600 in config(). Formula: Min(baseDelay * 2^attempt, maxDelay). The wheels_jobs table is auto-created on first enqueue/processing — no migration needed.
function notifications() {
var data = model("Notification").findAll(where="userId=#params.userId#");
renderSSE(data=SerializeJSON(data), event="notifications", id=params.lastId);
}
function stream() {
var writer = initSSEStream();
for (var item in items) sendSSEEvent(writer=writer, data=SerializeJSON(item), event="update");
closeSSEStream(writer=writer);
}
if (isSSERequest()) { renderSSE(data="..."); }Client: const es = new EventSource('/controller/notifications');
The canonical rules live in commitlint.config.js — if this section and the config disagree, the config wins.
type(scope): subject — scope is optional.
- type required.
- scope optional and unrestricted. Suggested:
model,controller,view,router,middleware,migrator,cli,test,config,di,job,mailer,plugin,sse,seed,docs, or static-site monorepo scopes likeweb,web/blog,web/guides. None enforced. - subject required, non-empty, not ALL-CAPS, header ≤ 100 chars, body lines ≤ 100 chars.
feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.
Notes:
ciis a TYPE, not a scope — never writerefactor(ci):.- DCO sign-off email must match
git config user.email— prefergit commit -sover manual trailer.
User-facing fix/feat PRs add a fragment file, never a direct CHANGELOG.md edit: write changelog.d/<slug>.<type>.md (type ∈ added,changed,deprecated,removed,fixed,security,performance) containing the complete markdown bullet line(s). Direct [Unreleased] edits recreate the same-anchor merge conflicts the fragment system removes. At release cut, tools/changelog-promote.sh <version> assembles fragments (plus any legacy [Unreleased] content) into the new version section and clears the folder. See changelog.d/README.md.
Canonical surface (Wheels 4.0+): the Wheels CLI's stdio MCP server at wheels mcp wheels.
{"mcpServers":{"wheels":{"command":"wheels","args":["mcp","wheels"]}}}There is no wheels mcp setup command — copy the JSON above into .mcp.json manually (see the MCP integration guide for OpenCode/Cursor variants).
Tools are auto-discovered from cli/lucli/Module.cfc public functions. Names in tools/list are the bare function names — NOT wheels_*-prefixed (live-verified on the released 4.0.3 CLI): analyze, create, db, deploy, destroy, doctor, generate, info, migrate, notes, packages, reload, routes, seed, stats, test, upgrade, validate (18 tools; the wheels server entry in .mcp.json namespaces them per client). CLI-only tools (main, mcp, d, g, new, console, start, stop, browser, jobs) are hidden via mcpHiddenTools().
Deprecated: the in-dev-server HTTP endpoint at /wheels/mcp. Emits a deprecation notice on first request. Migrate to the stdio surface.
wheelsIS the CLI. Built on the LuCLI runtime under the wheels brand — there is no separateluclibinary on a normal install. Older docs mentioningluclipredate the rebrand.
Prefer MCP tools when the Wheels MCP server is available. Fall back to CLI otherwise.
| Task | MCP | CLI |
|---|---|---|
| Generate | generate(type, name, attributes) |
wheels g model/controller/scaffold Name attrs |
| Migrate | migrate(action="latest|up|down|info|doctor") |
wheels migrate latest|up|down|info|doctor |
| Migrator reconciliation | — | wheels migrate forget|pretend <version> --yes (shared dev DB orphan cleanup; see #2780) |
| Test | test() |
wheels test |
| Reload | reload() |
?reload=true&password=... |
| Server | — | wheels start|stop |
| Analyze | analyze(target="all") |
— |
| Admin | — | wheels g admin ModelName |
| Seed | — | wheels seed |
Search .ai/ for deeper documentation:
- .ai/wheels/cross-engine-compatibility.md — Start here for Lucee/Adobe gotchas
- .ai/wheels/deploy.md —
wheels deployKamal port (extracted from CLAUDE.md) - .ai/wheels/wheels-bot.md — Bot architecture (extracted from CLAUDE.md)
- .ai/wheels/testing/browser-testing.md — Browser DSL (extracted from CLAUDE.md)
- .ai/wheels/testing/onboarding-harness.md — Fresh-install simulation
- .ai/wheels/controllers/api.md — API controller patterns
- .ai/wheels/views/query-association-patterns.md — Loop / include patterns
- .ai/wheels/security/https-detection.md
- .ai/wheels/channels/channels.md
- .ai/wheels/snippets/model-snippets.md, controller-snippets.md
- .ai/wheels/troubleshooting/common-errors.md, form-helper-errors.md
- .ai/wheels/troubleshooting/shared-dev-databases.md — Orphan-version handling +
migrate doctor/forget/pretendreconciliation commands (#2780) - .ai/cfml/ — CFML language reference (syntax, components, control flow)
External: user-facing guides at web/sites/guides/src/content/docs/v4-0-0/ (deployment, command-line-tools/mcp-integration, etc.) — these ship to guides.wheels.dev. Use when you need the version Wheels users read.