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 tag wrappers invendor/wheels/global/tags.cfm(mixed intowheels.Globalat runtime) were 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 intodevelop(it runs on the nightly cron,workflow_dispatch, and release PRs intomain). 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 intodevelop. 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.16b-ext — a closure whose first two statements are a zero-arg
$-member call then an argumented$-member call, Adobe 2025. The sameMissingNameExceptionfires when a closure body opens with_controller.$clearCachableActions()followed byg.$clearCache("action")(any receivers — local,g = application.wo, orapplication.woitself). Probe-verified boundaries: the pair compiles when it is not the first two statements (an assignment, an argumented call, or even another zero-arg call before it rescues it), when the first call has arguments, when the first call is not$-prefixed (_controller.flashClear()then_controller.$setFlashStorage("x")compiles), or when the second call is zero-arg. Fix by reordering (g.$clearCache("action")first) or hoisting avar/assignment ahead of the pair. Hit byControllerHardenerSpec/ControllerHardenerShouldSpecafterEachblocks (the Aug 28 matrix dispatch showed the whole adobe2025 leg attests="0").16c — a quoted-string named argument in a direct call, Adobe 2023 ONLY.
obj.method("name" = value)— a named argument whose name is a quoted string literal, in a direct method/function call — is a compile error on Adobe CF 2023 (MissingNameException), but compiles on Lucee 6/7, BoxLang, and Adobe 2025. This is the inverse footprint of its siblings: 16a fails both Adobe engines, 16b/16b-ext are Adobe 2025 only, and this one is the lone 2023-only shape — so an adobe2025-green smoke does NOT cover it. Unlike the closure-sensitive 16b-ext, this trap is context-independent — probe-verified on adobe2023/62023, it fails identically in all four call contexts tested: a single-expression arrow ((x) => obj.method("n" = x)), a block-bodied arrow, a regularfunction()closure, and a plain statement in a non-closure method body. The trigger is specifically a"string"name needed for a$-prefixed or hyphenated argument. Measured boundaries that compile on adobe2023 (do not "fix" them): an unquoted named argument (name = value), a quoted struct-literal key ({"$x" = 1}— it is a struct key, not a function-argument name), and theargumentCollectionworkaround below. Like the others, Adobe blames the enclosingdescribe(...)/functionline, and one occurrence zeroes the entire adobe2023 leg (tests="0"on every database) because the core suite compiles viadirectory=. Fix: build the arguments in a struct and pass them throughargumentCollection, which sidesteps argument-name parsing and is byte-identical in effect:// WRONG — MissingNameException on adobe2023 (green on Lucee/BoxLang/adobe2025) var out = c.URLFor(controller = "posts", action = "index", "$argsResolved" = true); // RIGHT — the $-prefixed name lives on a struct key, not an argument name var args = {controller = "posts", action = "index"}; args["$argsResolved"] = true; var out = c.URLFor(argumentCollection = args);Hit by
linkToUrlForDifferentialSpec(#4192); it was pre-existing and only the adobe2023 matrix legs caught it (Lucee-only local verification did not).Bisect this class of bug with a single probe against a running container instead of CI (~13s vs ~19min) — use the engine's own port (
62025for the Adobe 2025 shapes above,62023for the 16c Adobe 2023 shape):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);
-
The
applicationscope is unreliable insideonApplicationEnd()on Adobe CF 2023. DuringapplicationStop()teardown (triggered by a?reloadrestart or idle-timeout reclaim), bareapplication.wocan resolve against a stale/torn-down scope and land on a JavaString[], throwingElement wo is undefined in a Java object of type class [Ljava.lang.String;and erroring the whole site until a CF service restart. The only dependable reference at shutdown is the passed-inarguments.applicationScope. Always routeonApplicationEnd()through it and guard withStructKeyExistsso a partially reclaimed scope degrades to a no-op (#3379). Lucee 6/7 and BoxLang are unaffected; only Adobe CF exhibits this during real teardown.// WRONG — bare application.wo breaks on Adobe CF during applicationStop() teardown public void function onApplicationEnd(struct ApplicationScope) { application.wo.$include( template = "../../#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", argumentCollection = arguments ); } // RIGHT — route through the passed-in scope and guard before dereferencing public void function onApplicationEnd(struct ApplicationScope) { if ( StructKeyExists(arguments.applicationScope, "wo") && StructKeyExists(arguments.applicationScope, "wheels") && StructKeyExists(arguments.applicationScope.wheels, "eventPath") ) { arguments.applicationScope.wo.$include( template = "../../#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", argumentCollection = arguments ); } }The CLI template (
wheels new) and the demo app were fixed in #3380. Existing apps must apply the same change to theirpublic/Application.cfc.The same torn-down-scope rule applies to
onSessionEnd(). Adobe'sSessionTracker.SessionCleanUpAgentcan call it after the live application scope is already reclaimed, so bareapplication.wo.$simpleLockthrows the sameElement wo is undefined...error. Route througharguments.applicationScope.woand guard withStructKeyExists(arguments.applicationScope, "wo")only — this path uses$simpleLock, not$include, so do not add thewheels/eventPathchecks used byonApplicationEnd(). Existing 4.0.x apps must paste the same edit into their ownpublic/Application.cfc. -
BoxLang's
DirectoryList()returns a fixed-size array —ArrayAppendon it throws with NO message. The exception surfaces as anError-status spec with an emptyfailMessage/failDetail, and TestBox blames the closure's first line rather than the append. Copy into a fresh array before appending:var files = []; for (var f in DirectoryList(...)) { ArrayAppend(files, f); }. Iterating the result directly (for (var f in DirectoryList(...))) is fine. Hit byBareCfabortGuardSpec(Aug 29 matrix dispatch — BoxLang mysql+sqlite legs). Also note BoxLang's loose==coerces boolean-ish strings numerically —"1" == "yes"and"0" == "no"are TRUE, andListFindNoCase("yes,no", "1")returns 1 /("yes,no", "0")returns 2 for the same reason. UseCompareNoCase(a, b) == 0(orCompare) for string equality decisions; hit byQuoteValueSpec. -
On Adobe CF, a mixin UDF that creates a subclass whose pseudo-constructor copies that same UDF onto
thisfails the first time per JVM. Adobe CF 2023 and 2025 (Lucee and BoxLang are fine): the call's epilogue pops a super scope its prologue never pushed, so it throwsjava.util.EmptyStackException(or aNullPointerExceptiononthis.SymTab_superScopes) atNeoPageContext.popSuperScope. The call's own body completes normally. In Wheels,application.wo(a plainwheels.Global) runs theglobal/objects.cfmmixin$createObjectFromRootto createwheels.Public, and Global's pseudo-constructor ($promoteIncludedGlobalsToThis()) copies$createObjectFromRootonto the new object. That made the first request after every cold start a 500 at thePublicline inonapplicationstart.cfc(#3730). Any earlier request absorbs the one-time failure, including the Docker healthcheckGET /or a readiness retry. That is why the test suite and CI never see it, and why 51edf9dce "fixed" the wrong thing (a Public.$init include nest). Fix: create any Global subclass outside a mixin call first, which initializes the engine state for every Global mixin.onapplicationstart.$init()createswheels.events.SuperScopePrimeras its first statement; keep it first, and keep the primer empty. Guard:vendor/wheels/tests/specs/events/SuperScopePrimerSpec.cfc(structural, because the state is once per JVM). Verify a change in this area with a cold first request, healthcheck disabled, never the suite. CI runs exactly that on every engine in.github/workflows/cold-start-smoke.yml(tools/ci/cold-start-smoke.sh, #3735). Deep reference: .ai/wheels/cross-engine-compatibility.md. -
On BoxLang, a request that ends with
abortskips anyfinallywhosetryhas acatchclause — even an unreachedcatch (any) { rethrow; }. A catch-freetry/finallyruns, and so does one whose finally issues atransaction action=statement; whether the try or finally contains a transaction action makes no difference, the catch clause alone decides. Lucee 6/7 and Adobe run the finally either way, so local Lucee and Adobe green do NOT cover this. Keep must-run cleanup (a lock release, the rollback of a transaction on abort) in a catch-freetry/finally; to still tell a normal throw from an abort, nest atry/catchthat sets a flag inside it:var exitState = {completed = false, threw = false}; try { try { doWork(); exitState.completed = true; } catch (any e) { exitState.threw = true; rethrow; } } finally { if (!exitState.completed && !exitState.threw) { // only an abort gets here: run the must-run cleanup } }Hit twice: the session-lock release in
withAdvisoryLock(transaction = true)(#4330), andinvokeWithTransaction(), whose rollback lived in the finally of a try with a catch, so on BoxLang an abort inside it kept the writes made before the abort ($invokeTransactionMethod()invendor/wheels/model/transactions.cfm).
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, or at one spec file's dotted path, which runs as a single bundle (#3759). A path naming neither discovers 0 bundles; check bundlesDiscovered in the payload (#3083).
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() treats any action whose name matches one (or starts with $) as a missing action: it routes through $throwErrorOrShow404Page(type="Wheels.ActionNotAllowed"), so production renders the 404 page and development shows the Wheels.ActionNotAllowed error at status 404 (#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 404s 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()). Every column helper in TableDefinition.cfc accepts columnNames / columnName via $combineArguments; references and primaryKey also keep their legacy names (referenceNames, name) as aliases. Use columnNames.
// 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 resolve the same way (#4457). belongsTo(polymorphic=true) and hasMany/hasOne with as= record which of foreignKey / foreignType they derived at registration ($derivedPolymorphicColumns). On first use, $resolvePolymorphicColumns() (vendor/wheels/model/associations.cfm) checks the derived names against the child's table: <name>id / <name>type when they exist, otherwise <name>_id / <name>_type. The forms without an underscore win when both exist. Names passed explicitly are kept as given, and foreignType= (#4398) names a type column that matches neither convention.
- 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").
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.
Isolated test application (#3374): Application.cfc includes vendor/wheels/events/testcontext.cfm after config/app.cfm so runner URLs (and browser requests carrying the per-process runner secret) bind <this.name>_wheelsTest — a separate CFML application scope. The live application.wheels is not swapped. $testClient(testContext=false) addresses the live app. A request-scoped overlay cannot replace this (blockers B1–B9 on #3025). Existing apps without the include still use the #3373 named-lock swap on the live scope. Gated: the switch is honoured only when WHEELS_ENV ∈ {development, testing}; the runner path is matched against path_info/script_name; the X-Wheels-Test-Context header/cookie must equal the per-process server.$wheelsTestContextSecret from a loopback cgi.remote_addr. The authoritative teeth is a fail-closed backstop in events/onapplicationstart.cfc that refuses a _wheelsTest start whenever the config-resolved environment is not development/testing, before any start-up side effect; the runners also refuse to run in production and /wheels/app/tests defaults to the _test DB. The core suite therefore needs WHEELS_ENV=development (set in compose.yml and tools/test-local.sh); without it the runner falls back to the live scope and logs an isolation-off warning.
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.- Route-state save/restore in test specs:
wheels.WheelsTestprovides$snapshotRoutes(),$restoreRoutes(snapshot), and$clearRoutes()(#4194). Any spec that redefines the route table MUST snapshot inbeforeEachand restore inafterEach, or it leaks stale state into later specs (routes, staticRoutes, namedRoutePositions, the urlFor caches, and #4183's index are all separate — a partial restore is the #4192 bug class). Use the helpers rather than hand-rolling the save/restore:beforeEach(() => { variables._routes = $snapshotRoutes(); $clearRoutes(); g.mapper()...end(); g.$setNamedRoutePositions(); }); afterEach(() => $restoreRoutes(variables._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/
bash tools/test-local.sh database # any directory under vendor/wheels/tests/specs/ works
bash tools/test-local.sh model/hardener # nested directory (or wheels.tests.specs.model.hardener)
PORT=8191 bash tools/test-local.sh model # use another port when 8080 is taken
# An unknown area exits 2 with the list of valid areas — it never runs the full suite.
# 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. Every database gates CI, Oracle included: SOFT_FAIL_DBS in .github/workflows/compat-matrix.yml is empty, and even a soft-fail database fails its leg on a missing result or fewer than MIN_SPECS testcases (#3738). Narrow a dispatch with its engines / databases inputs (e.g. gh workflow run compat-matrix.yml --ref <branch> -f databases=oracle).
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. The Adobe (adobe2018/2021/2023/2025) and boxlang engines keep their server home (.engine/<engine>, with Adobe's compiled WEB-INF/cfclasses) on a per-engine named volume rather than the bind-mounted tree, so Docker's VM doesn't leak those file handles. tools/test-matrix.sh forces COMPOSE_PROJECT_NAME=wheels, so the volume name is keyed to the checkout — wheels_engine_<key>_<engine>, where <key> is a hash of the repo root (WHEELS_ENGINE_KEY, exported by the script; plain docker compose without it gets default) — so worktrees and clones don't share an engine home (which would let Adobe serve another checkout's compiled classes). Engine volumes survive --down like the DB volumes; removing them is opt-in. For a truly fresh engine home, remove this checkout's volume: docker volume rm wheels_engine_<key>_<engine> (list them with docker volume ls --filter name=wheels_engine). A plain docker compose up run outside tools/test-matrix.sh leaves WHEELS_ENGINE_KEY unset, so it uses the shared wheels_engine_default_<engine> volume — use the script (or set WHEELS_ENGINE_KEY yourself) when you want a checkout-isolated engine home. CI re-binds .engine/<engine> to the host via compose.ci.yml (COMPOSE_FILE=compose.yml:compose.ci.yml), because the compat matrix injects the Oracle JDBC driver and the cold-start smoke reads engine logs from that host path.
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 15 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.
The user-facing quick references — Model, Routing, Pagination helpers,
Middleware, DI container, Package system + CLI, Migrations & Seeding,
Background Jobs, SSE, and app testing — live in
docs/consumer-ai/CLAUDE.md (conventions, common mistakes, testing and a
topic index, kept under 8 KB) and docs/consumer-ai/.ai/<topic>.md
(the reference topics), which ship in every distributed artifact
(ForgeBox core, starter app, wheels new scaffolds) and is what AI tools
inside USER apps auto-load. Keep both files in sync: when a quick
reference changes here, update the consumer copy (or run
tools/build/scripts/ship-consumer-docs.sh check, which release.yml enforces).
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. - Never put a closing verb (
close,fixes,resolves, …) right before#Nin a PR body or commit message unless you mean it — GitHub ignores negation. "It does not close #3709" linked #3709 as a closing issue and the merge auto-closed it. For a non-closing link writeRefs #N, and confirm withgh pr view <n> --json closingIssuesReferencesbefore merge.
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"]}}}Run wheels setup agents to write .mcp.json and .opencode.json in the current project (it merges, preserving any other servers you already list). It is setup agents, not setup mcp: LuCLI intercepts the literal token mcp in any argument position
(wheels info mcp is intercepted too), so no argument may be spelled mcp. setup agents avoids the token entirely — 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 (verified by a stdio tools/list against the source module): analyze, db, destroy, doctor, generate, info, lookup, migrate, notes, packages, reload, routes, seed, stats, test, upgrade, validate (17 tools; the wheels server entry in .mcp.json namespaces them per client). CLI-only, stateful, and side-effecting commands (main, mcp, setup, d, g, new, create, console, start, stop, engines, browser, jobs, coverage, docs, deploy, …) are hidden via mcpHiddenTools(), which also auto-hides every public $-prefixed helper — read that function for the current list.
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. The tree is deliberately small —
the code is the source of truth, and the CFML-language / generic-pattern
reference tiers were removed (they drifted and modern models no longer
need them). What remains is maintainer-only runbook knowledge that is not
recoverable from reading the code:
- .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/troubleshooting/shared-dev-databases.md — Orphan-version handling +
migrate doctor/forget/pretendreconciliation commands (#2780)
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.