CFML MVC framework with ActiveRecord ORM. Models in app/models/, controllers in app/controllers/, views in app/views/, migrations in app/migrator/migrations/, config in config/, tests in tests/.
app/controllers/ app/models/ app/views/ app/views/layout.cfm
app/migrator/migrations/ app/db/seeds.cfm app/db/seeds/
app/events/ app/global/ app/lib/
app/mailers/ app/jobs/ app/plugins/ app/snippets/
config/settings.cfm config/routes.cfm config/environment.cfm
packages/ plugins/ public/ tests/ vendor/ .env (never commit)
Prefer MCP tools when the Wheels MCP server is available (mcp__wheels__*). Fall back to CLI otherwise.
| Task | MCP | CLI |
|---|---|---|
| Generate | wheels_generate(type, name, attributes) |
wheels g model/controller/scaffold Name attrs |
| Migrate | wheels_migrate(action="latest|up|down|info") |
wheels dbmigrate latest|up|down|info |
| Test | wheels_test() |
wheels test run |
| Reload | wheels_reload() |
?reload=true&password=... |
| Server | wheels_server(action="status") |
wheels server start|stop|status |
| Analyze | wheels_analyze(target="all") |
— |
| Admin | — | wheels g admin ModelName |
| Seed | — | wheels db:seed |
These are the most common mistakes when generating Wheels code. Check every time.
Wheels functions cannot mix positional and named arguments. This is the #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>Wheels supports nested resources via the callback parameter or nested=true with manual end(). Do NOT use Rails-style inline function blocks.
// 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()
// RIGHT — flat separate declarations (no URL nesting)
.resources("posts")
.resources("comments")Wheels provides dedicated HTML5 input helpers. Use them instead of manual type attributes.
// Object-bound helpers
#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-based helpers
#emailFieldTag(name="email", value="")#
#numberFieldTag(name="qty", value="1", min="0", step="1")#Parameter binding in execute() is unreliable. Use inline SQL for seed data.
// WRONG
execute(sql="INSERT INTO roles (name) VALUES (?)", parameters=[{value="admin"}]);
// RIGHT
execute("INSERT INTO roles (name, createdAt, updatedAt) VALUES ('admin', NOW(), NOW())");Routes are matched first-to-last. Wrong order = wrong matches.
Order: MCP routes → resources → custom named routes → root → wildcard (last!)
Don't also add separate datetime columns for these.
// WRONG — duplicates
t.timestamps();
t.datetime(columnNames="createdAt");
// RIGHT
t.timestamps(); // creates both createdAt and updatedAtUse NOW() — it works across MySQL, PostgreSQL, SQL Server, H2, SQLite.
// WRONG — database-specific
execute("INSERT INTO users (name, createdAt) VALUES ('Admin', CURRENT_TIMESTAMP)");
// RIGHT
execute("INSERT INTO users (name, createdAt, updatedAt) VALUES ('Admin', NOW(), NOW())");Filter functions (authentication, data loading) must be declared private.
// WRONG — public filter becomes a routable action
function authenticate() { ... }
// RIGHT
private function authenticate() { ... }Every variable passed from controller to view needs a cfparam declaration.
// At top of every view file
<cfparam name="users" default="">
<cfparam name="user" default="">- config(): All model associations/validations/callbacks and controller filters/verifies go in
config() - Naming: Models are singular PascalCase (
User.cfc), controllers are plural PascalCase (Users.cfc), table names are 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"for RocketUnit) - Associations: All named params when using options:
hasMany(name="orders"),belongsTo(name="user"),hasOne(name="profile") - Validations: Property param is
property(singular) for single,properties(plural) for list:validatesPresenceOf(properties="name,email")
component extends="Model" {
function config() {
// Table/key (only if non-conventional)
tableName("tbl_users");
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");
// 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});
}
// Dynamic scope handler (must return struct with query keys)
private struct function scopeByRole(required string role) {
return {where: "role = '#arguments.role#'"};
}
}Finders: model("User").findAll(), model("User").findOne(where="..."), model("User").findByKey(params.key).
Create: model("User").new(params.user) then .save(), or model("User").create(params.user).
Include associations: findAll(include="role,orders"). Pagination: findAll(page=params.page, perPage=25).
// Chain scopes together — each adds to the query
model("User").active().recent().findAll();
model("User").byRole("admin").findAll(page=1, perPage=25);
model("User").active().recent().count();// Fluent alternative to raw WHERE strings — values are auto-quoted
model("User")
.where("status", "active")
.where("age", ">", 18)
.whereNotNull("emailVerifiedAt")
.orderBy("name", "ASC")
.limit(25)
.get();
// Combine with scopes
model("User").active().where("role", "admin").get();
// Other builder methods: orWhere, whereNull, whereBetween, whereIn, whereNotIn// Auto-generated boolean checkers
user.isDraft(); // true/false
user.isPublished(); // true/false
// Auto-generated scopes per value
model("User").draft().findAll();
model("User").published().findAll();// Process one record at a time (loads in batches internally)
model("User").findEach(batchSize=1000, callback=function(user) {
user.sendReminderEmail();
});
// Process in batch groups (callback receives query/array)
model("User").findInBatches(batchSize=500, callback=function(users) {
processUserBatch(users);
});
// Works with scopes and conditions
model("User").active().findEach(batchSize=500, callback=function(user) { /* ... */ });Middleware runs at the dispatch level, before controller instantiation. Each implements handle(request, next).
// config/settings.cfm — global middleware (runs on every request)
set(middleware = [
new wheels.middleware.RequestId(),
new wheels.middleware.SecurityHeaders(),
new wheels.middleware.Cors(allowOrigins="https://myapp.com")
]);// config/routes.cfm — route-scoped middleware
mapper()
.scope(path="/api", middleware=["app.middleware.ApiAuth"])
.resources("users")
.end()
.end();Built-in: wheels.middleware.RequestId, wheels.middleware.Cors, wheels.middleware.SecurityHeaders, wheels.middleware.RateLimiter. Custom middleware: implement wheels.middleware.MiddlewareInterface, place in app/middleware/.
Register services in config/services.cfm (loaded at app start, environment overrides supported):
var di = injector();
di.map("emailService").to("app.lib.EmailService").asSingleton();
di.map("currentUser").to("app.lib.CurrentUserResolver").asRequestScoped();
di.bind("INotifier").to("app.lib.SlackNotifier").asSingleton();Resolve with service() anywhere, or use inject() in controller config():
// In any controller/view
var svc = service("emailService");
// Declarative injection in controller config()
function config() {
inject("emailService, currentUser");
}
function create() {
this.emailService.send(to=user.email); // resolved per-request
}Scopes: transient (default, new each call), .asSingleton() (app lifetime), .asRequestScoped() (per-request via request.$wheelsDICache). Auto-wiring: init() params matching registered names are auto-resolved when no initArguments passed. bind() = semantic alias for map().
// Fixed window (default) — 60 requests per 60 seconds
new wheels.middleware.RateLimiter()
// Sliding window — smoother enforcement
new wheels.middleware.RateLimiter(maxRequests=100, windowSeconds=120, strategy="slidingWindow")
// Token bucket — allows bursts up to capacity, refills steadily
new wheels.middleware.RateLimiter(maxRequests=50, windowSeconds=60, strategy="tokenBucket")
// Database-backed storage (auto-creates wheels_rate_limits table)
new wheels.middleware.RateLimiter(storage="database")
// Custom key function (rate limit per API key instead of IP)
new wheels.middleware.RateLimiter(keyFunction=function(req) {
return req.cgi.http_x_api_key ?: "anonymous";
})Strategies: fixedWindow (default), slidingWindow, tokenBucket. Storage: memory (default) or database. Adds X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers. Returns 429 Too Many Requests with Retry-After when limit exceeded.
Optional first-party modules ship in packages/ and are activated by copying to vendor/. The framework auto-discovers vendor/*/package.json on startup via PackageLoader.cfc with per-package error isolation.
packages/ # Source/staging (NOT auto-loaded)
sentry/ # wheels-sentry — error tracking
hotwire/ # wheels-hotwire — Turbo/Stimulus
basecoat/ # wheels-basecoat — UI components
vendor/ # Runtime: framework core + activated packages
wheels/ # Framework core (excluded from package discovery)
sentry/ # Activated package (copied from packages/)
plugins/ # DEPRECATED: legacy plugins still work with warning
{
"name": "wheels-sentry",
"version": "1.0.0",
"author": "PAI Industries",
"description": "Sentry error tracking",
"wheelsVersion": ">=3.0",
"provides": {
"mixins": "controller",
"services": [],
"middleware": []
},
"dependencies": {}
}provides.mixins: Comma-delimited targets — controller, view, model, global, none. Determines which framework components receive the package's public methods. Default: none (explicit opt-in, unlike legacy plugins which default to global).
cp -r packages/sentry vendor/sentry # activate
rm -rf vendor/sentry # deactivateRestart or reload the app after activation. Symlinks also work: ln -s ../../packages/sentry vendor/sentry.
Each package loads in its own try/catch. A broken package is logged and skipped — the app and other packages continue normally.
# Run a specific package's tests (package must be in vendor/)
curl "http://localhost:60007/wheels/core/tests?db=sqlite&format=json&directory=vendor.sentry.tests"// config/routes.cfm
mapper()
.resources("users") // standard CRUD
.resources("products", except="delete") // skip actions
.resources(name="posts", callback=function(map) { // nested resources
map.resources("comments");
map.resources("tags");
})
.get(name="login", to="sessions##new") // named route
.post(name="authenticate", to="sessions##create")
.root(to="home##index", method="get") // homepage
.wildcard() // keep last!
.end();Helpers: linkTo(route="user", key=user.id, text="View"), urlFor(route="users"), redirectTo(route="user", key=user.id), startFormTag(route="user", method="put", key=user.id).
Automatically resolves params.key into a model instance before the controller action runs. The instance lands in params.<singularModelName> (e.g., params.user). Throws Wheels.RecordNotFound (404) if the record doesn't exist; silently skips if the model class doesn't exist.
// Per-resource — convention: singularize controller name → model
.resources(name="users", binding=true)
// Explicit model name override
.resources(name="posts", binding="BlogPost") // resolves BlogPost, stored in params.blogPost
// Scope-level — all nested resources inherit binding
.scope(path="/api", binding=true)
.resources("users") // params.user
.resources("products") // params.product
.end()
// Global — enable for all resource routes
set(routeModelBinding=true); // in config/settings.cfmIn the controller, use the resolved instance directly:
function show() {
user = params.user; // already a model object, no findByKey needed
}Requires a paginated query: findAll(page=params.page, perPage=25). The recommended all-in-one helper is paginationNav().
// All-in-one nav (wraps first/prev/page-numbers/next/last in <nav>)
#paginationNav()#
#paginationNav(showInfo=true, showFirst=false, showLast=false, navClass="my-pagination")#
// Individual helpers for custom layouts
#paginationInfo()# // "Showing 26-50 of 1,000 records"
#firstPageLink()# // link to page 1
#previousPageLink()# // link to previous page
#pageNumberLinks()# // windowed page number links (default windowSize=2)
#nextPageLink()# // link to next page
#lastPageLink()# // link to last page
#pageNumberLinks(windowSize=5, classForCurrent="active")#Disabled links render as <span class="disabled"> by default. All helpers accept handle for named pagination queries.
All new tests use WheelsTest BDD syntax. RocketUnit (test_ prefix, assert()) is legacy only — never use it for new tests.
- App tests:
/wheels/app/tests— project-specific tests intests/specs/. Usestests/populate.cfmfor test data andtests/TestRunner.cfcfor setup. - Core tests:
/wheels/core/tests— framework tests invendor/wheels/tests/specs/. Usesvendor/wheels/tests/populate.cfm. This is what CI runs across all engines × databases.
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.
// tests/specs/models/MyFeatureSpec.cfc
component extends="wheels.WheelsTest" {
function run() {
describe("My Feature", () => {
it("validates presence of name", () => {
var user = model("User").new();
expect(user.valid()).toBeFalse();
});
});
}
}- Specs:
tests/specs/models/,tests/specs/controllers/,tests/specs/functional/ - Test models:
tests/_assets/models/(usetable()to map to test tables) - Test data:
tests/populate.cfm(DROP + CREATE tables, seed data) - Runner URL:
/wheels/app/tests?format=json&directory=tests.specs.models - Force reload: append
&reload=trueafter adding new model CFCs - Closure gotcha: CFML closures can't access outer
localvars — use shared structs (var result = {count: 0}) - Scope gotcha in test infra: Wheels internal functions (
$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 gotcha: HTML entities likeocontain#which CFML interprets as expression delimiters. In string literals, escape as&##111;. Comments (//) are fine since they aren't evaluated. Unescaped#in strings causes "Invalid Syntax Closing [#] not found" compilation errors that crash the entire test suite (not just that file).$clearRoutes()in test specs: Test CFCs that manipulate routes must define their own$clearRoutes()method — it is NOT inherited fromwheels.WheelsTest. Copy fromlinksSpec.cfc.Left(str, 0)crashes Lucee 7: Use a ternary guard:local.match.pos[1] > 1 ? Left(str, local.match.pos[1] - 1) : ""- Run with MCP
wheels_test()or CLIwheels test run
IMPORTANT: Always run the test suite before pushing. Do not rely on CI alone.
bash tools/test-local.sh # run all core tests
bash tools/test-local.sh model # run model tests only
bash tools/test-local.sh security # run security tests onlyThe script handles everything: creates SQLite DBs, starts a LuCLI server if needed, runs tests, reports results, cleans up. No Docker required.
# Install LuCLI (0.3.3+ recommended)
brew install lucli # or download from GitHub releases
# Java 21 required
brew install openjdk@21cd /path/to/wheels
sqlite3 wheelstestdb.db "SELECT 1;"
sqlite3 wheelstestdb_tenant_b.db "SELECT 1;"
lucli server run --port=8080
# In another terminal:
curl -s "http://localhost:8080/?reload=true&password=wheels"
curl -sf "http://localhost:8080/wheels/core/tests?db=sqlite&format=json" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(f'{d[\"totalPass\"]} pass, {d[\"totalFail\"]} fail, {d[\"totalError\"]} error')"bash tools/test-local.sh model # vendor/wheels/tests/specs/model/
bash tools/test-local.sh controller # vendor/wheels/tests/specs/controller/
bash tools/test-local.sh view # vendor/wheels/tests/specs/view/
bash tools/test-local.sh security # vendor/wheels/tests/specs/security/
bash tools/test-local.sh middleware # vendor/wheels/tests/specs/middleware/
bash tools/test-local.sh dispatch # vendor/wheels/tests/specs/dispatch/
bash tools/test-local.sh migrator # vendor/wheels/tests/specs/migrator/Docker is still supported for cross-engine testing (Adobe CF, multiple Lucee versions, multiple databases). For day-to-day development, use the LuCLI method above.
Lucee and Adobe CF have different runtime behaviors (struct member functions, application scope, closure scoping). Always test at least two engines:
cd /path/to/wheels/rig # must be in the repo root with compose.yml
# Start both engines (SQLite is built-in on all engines, no external DB needed)
docker compose up -d lucee6 adobe2025
# Wait ~60s for startup, then run both:
curl -s -o /tmp/lucee6-results.json "http://localhost:60006/wheels/core/tests?db=sqlite&format=json"
curl -s -o /tmp/adobe2025-results.json "http://localhost:62025/wheels/core/tests?db=sqlite&format=json"
# Check results (HTTP 200=pass, 417=failures)
for f in /tmp/lucee6-results.json /tmp/adobe2025-results.json; do
python3 -c "
import json
d = json.load(open('$f'))
engine = '$f'.split('/')[-1].replace('-results.json','')
print(f'{engine}: {d[\"totalPass\"]} pass, {d[\"totalFail\"]} fail, {d[\"totalError\"]} error')
for b in d.get('bundleStats',[]):
for s in b.get('suiteStats',[]):
for sp in s.get('specStats',[]):
if sp.get('status') in ('Failed','Error'):
print(f' {sp[\"status\"]}: {sp[\"name\"]}: {sp.get(\"failMessage\",\"\")[:120]}')
"
done| Engine | Port |
|---|---|
| lucee5 | 60005 |
| lucee6 | 60006 |
| lucee7 | 60007 |
| adobe2018 | 62018 |
| adobe2021 | 62021 |
| adobe2023 | 62023 |
| adobe2025 | 62025 |
| boxlang | 60001 |
docker compose up -d lucee6 mysql
curl -sf "http://localhost:60006/wheels/core/tests?db=mysql&format=json" > /tmp/results.jsoncurl "http://localhost:60006/wheels/core/tests?db=sqlite&format=json&directory=tests.specs.controller"Always verify Adobe CF fixes locally before pushing — don't iterate via CI. Test against the local container directly:
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')"- struct.map(): Lucee/Adobe resolve
obj.map()as the built-in struct member function, not the CFC method. UsemapInstance()on the Injector. - Application scope: Adobe CF doesn't support function members on the
applicationscope. Pass a plain struct context instead. - Closure this: CFML closures capture
thisfrom the declaring scope. Usevar ctx = {ref: obj}to share references across closures. - Bracket-notation function call:
obj["key"]()crashes Adobe CF 2021/2023 parser inside closures. Split into two statements:var fn = obj["key"]; fn(). - Array by-value in struct literals: Adobe CF copies arrays by value in
{arr = myArray}. Closures that append to the copy won't affect the original. Reference via parent struct instead:{owner = parentStruct}thenowner.arr. privatemixin functions not integrated:$integrateComponents()only copiespublicmethods into model/controller objects. ALL helper functions in mixin CFCs (vendor/wheels/model/*.cfc, view helpers, etc.) MUST usepublicaccess. Use$prefix for internal scope instead ofprivatekeyword. BoxLang handles this differently, soprivatemay pass BoxLang tests but fail Lucee/Adobe.
CockroachDB is marked as soft-fail in .github/workflows/tests.yml — failures are logged as warnings but don't block the build. The SOFT_FAIL_DBS variable controls this. Remove a database from the list once its tests are fixed.
docker compose down # Stop all containersGenerate migrations from model/DB schema diffs. Rename detection via explicit hints (authoritative) + heuristic suggestions (normalized-token + Levenshtein).
// Programmatic
var am = CreateObject("component", "wheels.migrator.AutoMigrator");
// Single model
var d = am.diff("User");
var d = am.diff("User", {renames: {"full_name": "fullName"}});
var d = am.diff("User", {heuristicThreshold: 0.85});
// All models (per-model hints keyed by model name)
var all = am.diffAll({
hints: {"User": {renames: {"full_name": "fullName"}}},
heuristicThreshold: 0.7
});
// Write migration CFC from diff result
am.writeMigration(d, "rename_name_field");# CLI
wheels dbmigrate diff User # preview
wheels dbmigrate diff User --rename=full_name:fullName # with hint
wheels dbmigrate diff User --write --name=rename_name # commit file
wheels dbmigrate diff --threshold=0.85 # all models, stricter
wheels dbmigrate diff --rename=User.full_name:fullName # diffAll hintDiff result struct:
{modelName, tableName,
addColumns, removeColumns, changeColumns, // pruned of rename pairs
renameColumns, // confirmed renames (emitted into up/down)
suggestedRenames} // heuristic candidates for display
Limits: PK renames not detected; rename + type change requires separate migrations; calculated properties excluded from diff.
Convention-based, idempotent seeding with CLI support.
// app/db/seeds.cfm — Shared seeds (runs in all environments)
seedOnce(modelName="Role", uniqueProperties="name", properties={
name: "admin", description: "Administrator"
});
seedOnce(modelName="Role", uniqueProperties="name", properties={
name: "member", description: "Regular member"
});
// app/db/seeds/development.cfm — Dev-only seeds (runs after seeds.cfm)
seedOnce(modelName="User", uniqueProperties="email", properties={
firstName: "Dev", lastName: "User", email: "dev@example.com"
});CLI:
wheels db:seed # Run convention seeds (auto-detect)
wheels db:seed --environment=production # Seed for specific environment
wheels db:seed --generate # Generate random test data (legacy)
wheels db:seed --generate --count=10 # Generate 10 records per model
wheels generate seed # Create app/db/seeds.cfm
wheels generate seed --all # Create seeds.cfm + dev/prod stubsseedOnce() — idempotent: checks uniqueProperties via findOne(), creates only if not found. Re-running seeds is always safe.
Execution order: app/db/seeds.cfm (shared) → app/db/seeds/<environment>.cfm (env-specific). Wrapped in a transaction.
Seeder component: application.wheels.seeder (initialized alongside migrator). Call application.wheels.seeder.runSeeds() programmatically.
// Define a job: 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 from a controller
job = new app.jobs.SendWelcomeEmailJob();
job.enqueue(data={email: user.email}); // immediate
job.enqueueIn(seconds=300, data={email: "..."}); // delayed 5 minutes
job.enqueueAt(runAt=scheduledDate, data={}); // at specific time
// Process jobs (call from scheduled task or controller)
job = new wheels.Job();
result = job.processQueue(queue="mailers", limit=10);
// Queue management
stats = job.queueStats(); // {pending, processing, completed, failed, total}
job.retryFailed(queue="mailers"); // retry all failed jobs
job.purgeCompleted(days=7); // clean up old completed jobsJob Worker CLI — persistent daemon for processing jobs:
wheels jobs work # process all queues
wheels jobs work --queue=mailers --interval=3 # specific queue, 3s poll
wheels jobs status # per-queue breakdown
wheels jobs status --format=json # JSON output
wheels jobs retry --queue=mailers # retry failed jobs
wheels jobs purge --completed --failed --older-than=30
wheels jobs monitor # live dashboardConfigurable backoff: this.baseDelay = 2 and this.maxDelay = 3600 in job config(). Formula: Min(baseDelay * 2^attempt, maxDelay).
Requires migration: 20260221000001_createwheels_jobs_table.cfc. Run with wheels dbmigrate latest.
// In a controller action — single event response
function notifications() {
var data = model("Notification").findAll(where="userId=#params.userId#");
renderSSE(data=SerializeJSON(data), event="notifications", id=params.lastId);
}
// Streaming multiple events (long-lived connection)
function stream() {
var writer = initSSEStream();
for (var item in items) {
sendSSEEvent(writer=writer, data=SerializeJSON(item), event="update");
}
closeSSEStream(writer=writer);
}
// Check if request is from EventSource
if (isSSERequest()) { renderSSE(data="..."); }Client-side: const es = new EventSource('/controller/notifications');
Shipped in v4.0 across PRs #2113, #2115, #2116. Specs extend wheels.wheelstest.BrowserTest and drive a real Chromium through this.browser — a fluent DSL wrapping Playwright Java.
// vendor/wheels/tests/specs/browser/LoginBrowserSpec.cfc
component extends="wheels.wheelstest.BrowserTest" {
this.browserEngine = "chromium"; // chromium only in PR 1
function run() {
// browserDescribe() wraps describe() with beforeEach/afterEach that
// create a fresh Page per `it`. WheelsTest's BDD lifecycle only treats
// beforeAll/afterAll as class-level, so we register per-it hooks
// from inside the suite body via this helper.
browserDescribe("Login flow", () => {
it("can load a page and read its title", () => {
if (this.browserTestSkipped) return;
this.browser.visitUrl("data:text/html,<title>Hi</title><h1>x</h1>")
.assertTitleContains("Hi");
});
});
}
}Install Playwright locally before first run (~370MB download: JARs + Chromium):
wheels browser:install # downloads JARs + ChromiumThen run browser specs via the normal test suite:
bash tools/test-local.sh # skips browser specs if JARs missing- Navigation: visit, visitUrl, back, forward, refresh, visitRoute
- Interaction: click, press, fill, type, clear, select, check, uncheck, attach, dragAndDrop
- Keyboard: keys, pressEnter, pressTab, pressEscape
- Waiting: waitFor, waitForText, waitForUrl
- Scoping: within(selector, callback)
- Cookies: setCookie, deleteCookie, cookie, clearCookies
- Auth: loginAs, logout
- Dialogs: acceptDialog, dismissDialog, dialogMessage (Lucee-only via createDynamicProxy)
- Viewport: resize, resizeToMobile, resizeToTablet, resizeToDesktop
- Script: script (returns
page.evaluateresult), pause - Assertions (text/vis/presence): assertSee, assertDontSee, assertSeeIn, assertVisible, assertMissing, assertPresent, assertNotPresent
- Assertions (URL/title/query): assertUrlIs, assertUrlContains, assertTitleContains, assertQueryStringHas, assertQueryStringMissing, assertRouteIs
- Assertions (form): assertInputValue, assertChecked, assertHasClass
- Terminals: currentUrl, title, pageSource, text, value, screenshot
##in selectors — CFML requires##to emit literal#."##email"→"#email"at runtime.clientis a Lucee reserved scope.var client = ...in a closure throws "client scope is not enabled". Usevar c = ...orvar bc = ....- Data URLs work for most tests — no server needed for ~95% of DSL coverage. Full HTTP integration (cookies, form submits, redirects) needs a running fixture app; that wiring is the same as Wheels Web app bootstrap (separate server + baseUrl).
this.browserTestSkipped— when Playwright JARs aren't installed (fresh CI, clean machine),beforeAllsets this flag andbrowserDescribe's hooks short-circuit. Allits should checkif (this.browserTestSkipped) return;to stay green on CI.- CI runs browser tests —
pr.ymlandsnapshot.ymlinstall Playwright JARs + Chromium (cached viabrowser-manifest.jsonhash). Browser specs run as part of the normal test suite.WHEELS_BROWSER_TEST_BASE_URL=http://localhost:60007is set automatically. - Fixture routes —
/_browser/login-asand/_browser/logoutare mounted automatically in test mode. They must come before.wildcard()in routes.cfm. - Dialogs are Lucee-only —
acceptDialog,dismissDialog,dialogMessageusecreateDynamicProxywhich is Lucee-specific. Specs skip gracefully on other engines.
Full reference: .ai/wheels/testing/browser-testing.md.
Deeper documentation lives in .ai/ — Claude will search it automatically when needed:
.ai/wheels/cross-engine-compatibility.md— Start here for Lucee/Adobe cross-engine gotchas.ai/cfml/— CFML language reference (syntax, data types, components, control flow, best practices).ai/wheels/core-concepts/— MVC architecture, ORM mapping, routing conventions, Rails comparison.ai/wheels/models/— ORM details, associations, validations, scopes, enums, batch processing.ai/wheels/controllers/— actions, filters, rendering (JSON/views/redirects), security, SSE, parameter verification.ai/wheels/views/— layouts, partials, form helpers (including HTML5), link helpers, pagination, forms.ai/wheels/database/— migrations, queries, associations, validations, seeding.ai/wheels/configuration/— routing, environments, settings, DI container, multi-tenancy, security.ai/wheels/middleware/— pipeline structure, rate limiting, tenant resolver.ai/wheels/jobs/— background job queue, retries, priority queues.ai/wheels/mcp/— AI agent integration via LuCLI stdio MCP (setup, tool reference, auto-discovery).ai/wheels/packages/— first-party packages (sentry, hotwire, basecoat) + activation model.ai/wheels/cli/— generators (model, controller, scaffold, admin, migrations).ai/wheels/testing/— WheelsTest BDD, browser testing, browser automation patterns.ai/wheels/security/— CSRF protection, HTTPS detection.ai/wheels/patterns/— authentication, CRUD, validation templates.ai/wheels/snippets/— copy-paste model + controller examples.ai/wheels/troubleshooting/— common errors, form helper errors
This repo uses commitlint. Commit messages must follow: type(scope): lowercase subject
Valid scopes: model, controller, view, router, middleware, migration, cli, test, config, di, job, mailer, plugin, sse, seed, docs
Invalid scope: security — use the layer it touches (e.g., model for SQL injection fix, view for XSS fix, config for consoleeval hardening, cli for MCP server fixes).
Subject must be lowercase. No sentence-case, start-case, or pascal-case. Write fix(model): validate index names not fix(model): Validate index names.
The project name is Wheels (not "CFWheels"). The rebrand happened at v3.0. Always use "Wheels" in new code, comments, commit messages, PR descriptions, and documentation.
Canonical surface (Wheels 4.0+): LuCLI stdio MCP at wheels mcp wheels. Configure your AI IDE with:
{"mcpServers":{"wheels":{"command":"wheels","args":["mcp","wheels"]}}}Or run wheels mcp setup to generate .mcp.json + .opencode.json automatically.
Tools are auto-discovered from cli/lucli/Module.cfc public functions, prefixed with the module name (wheels_generate, wheels_migrate, wheels_test, wheels_reload, wheels_seed, wheels_analyze, wheels_validate, wheels_routes, wheels_info, wheels_destroy, wheels_doctor, wheels_stats, wheels_notes, wheels_db, wheels_upgrade, wheels_create). CLI-only tools (mcp, d, new, console, start, stop, browser) are hidden from MCP tools/list via mcpHiddenTools().
Workflow orchestration (multi-step planning, feature development) is not a framework concern — use your preferred Claude Code plugin (Superpowers, feature-dev, etc.). The framework ships deterministic Wheels operations via MCP; the model orchestrates.
Deprecated: The in-dev-server HTTP endpoint at /wheels/mcp (routed from vendor/wheels/public/views/mcp.cfm). Emits a deprecation notice and warning log on first request. Scheduled for removal in a future release — migrate to the stdio surface. See docs/command-line-tools/commands/mcp/mcp-configuration-guide.md.