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.
// 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 TestBox 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). - Run with MCP
wheels_test()or CLIwheels test run
IMPORTANT: Always run the test suite before pushing. Do not rely on CI alone.
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 (H2 is built-in, 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=h2&format=json"
curl -s -o /tmp/adobe2025-results.json "http://localhost:62025/wheels/core/tests?db=h2&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=h2&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 containersConvention-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');
Deeper documentation lives in .ai/ — Claude will search it automatically when needed:
.ai/wheels/cross-engine-compatibility.md— Start here for Lucee/Adobe/H2 gotchas.ai/cfml/— CFML language reference (syntax, data types, components).ai/wheels/models/— ORM details, associations, validations, scopes, enums.ai/wheels/controllers/— filters, rendering, security.ai/wheels/views/— layouts, partials, form helpers (including HTML5), link helpers.ai/wheels/database/— migrations, queries, seeding, advanced operations.ai/wheels/cli/— generators (including admin generator).ai/wheels/testing/— unit testing with TestBox, test infrastructure, common gotchas.ai/wheels/configuration/— routing, environments, settings, DI container
Endpoint: /wheels/mcp (routes must come before .wildcard() in routes.cfm).
Tools: wheels_generate, wheels_migrate, wheels_test, wheels_server, wheels_reload, wheels_analyze, wheels_validate.