Pass 2: 3.x to 4.1 with `wheels upgrade check`
Pass 2 takes the example app from Wheels 3.0.0+33 to 4.1.2 in 14 commits on upgrade/pass-2-wheels-4.1. Pass 1 did the layout move. This pass is the framework swap and the 4.x breaking changes, and it starts with wheels upgrade check.
Run the check before you swap vendor/wheels
wheels upgrade check is a read-only scan of your app for the patterns that break between your version and the target. Run it from the project root with the 4.1 CLI, while vendor/wheels/ is still 3.0:
wheels upgrade check --to=4.1.2
On the 3.0 example app it reported five breaking changes and two advisories (output trimmed):
Current version: unknown
Target version: 4.1.2
Breaking Changes (5 found):
! Old test base class (wheels.Test / wheels.Testbox)
tests/RocketUnit/Test.cfc:2
! Legacy plugin directory (deprecated as of 4.0, removed in 5.0)
plugins/
! Old test base class (wheels.Test / wheels.Testbox)
tests/RocketUnit/Test.cfc:2
! Missing csrfCookieEncryptionSecretKey (CSRF cookies rotate on every deploy when csrfStore="cookie")
config/ (no occurrences found)
! Deprecated paginationLinks() helper (renamed to paginationNav() in 4.0)
app/views/admin/auditlogs/index.cfm:38
app/views/admin/users/index.cfm:74
Recommended Improvements (2 found):
~ CSRF cookie sets SameSite in 4.0 (advisory: review cross-site POST flows)
~ App template drift — public/Application.cfc or public/index.cfm differs from the bundled template
Breaking findings make the command exit non-zero, so it can gate CI. Some of this output needs reading with care:
Current version: unknown. The check reads the version fromvendor/wheels/wheels.jsonorvendor/wheels/box.json. The 3.0.0+33box.jsonhas no version, so the check falls back to “unknown”, runs the 2.x rules as well, and reports the test base class twice.- The CSRF key is a false positive here. The key only matters for
csrfStore="cookie". This app uses the default session store, and the check only looks for the setting name inconfig/. - Don’t take its RocketUnit advice. It suggests
extends="wheels.WheelsTest"fortests/RocketUnit/Test.cfc. That breaks every RocketUnit test that inherits from it.
After all 14 commits, the check says:
Current version: 4.1.2
Target version: 4.1.2
Same major version — no known breaking changes.
Scanning for opt-in recommendations...
All Clear (3 checks):
That’s correct, but note why. Once vendor/wheels/ reports 4.1.2, the check compares 4.1.2 with 4.1.2 and skips every 3→4 rule. Swap first and check second, and you’ll see “all clear” with the work still to do. Run the check before the swap, and keep the output as your to-do list. These four problems are filed as #3939.
Swap the framework
10d921e replaces vendor/wheels/ with the contents of wheels-core-4.1.2.zip from the GitHub release, extracted fresh so files deleted in 4.x don’t linger. Wheels 4 has dependency injection and testing built in, so WireBox and TestBox leave box.json:
- "wheels-core":"3.0.0+33",
- "wirebox":"^7.0.0",
- "testbox":"^6.0.0",
+ "wheels-core":"4.1.2",
wheels upgrade apply does the same swap with the framework bundled in your CLI, and backs up the old folder to vendor/wheels.bak-<timestamp>/ first. Either way, the app won’t boot until the next step.
Adopt the 4.1 public/Application.cfc
The swap doesn’t touch this app-owned file, so 02da512 copies the 4.1.2 template over it verbatim. The core change is dependency injection:
- wirebox = new wirebox.system.ioc.Injector("wheels.Wirebox");
+ application.wheelsdi = new wheels.Injector("wheels.Bindings");
It also brings the Adobe CF teardown guards in onApplicationEnd/onSessionEnd (4.0.6 and 4.1.0, #3379, reported by @mikegrogan), fail-closed reloads, and a session cookie that’s secure over HTTPS. config/app.cfm forced this.sessioncookie.secure = false, which would undo that, so the line went.
Three things only a real boot found
Migrations with default=''. wheels migrate latest on an empty MySQL database stopped at the first migration:
An empty string default is not allowed for string columns.
Since 4.1.0 the migrator throws Wheels.InvalidDefault for default='' on string, text and char columns. Applied migrations never re-run, so you only find out on a fresh database: a new developer, CI, a test database. 77b4729 drops the argument from nine columns, which gives MySQL the same DDL as before:
- t.string(columnNames="name,description", default='', null=false );
+ t.string(columnNames="name,description", null=false );
A struct passed to a scalar column. A failed login returned a 500:
Cannot assign a array value to scalar column `data` on the `auditlog` model
Since 4.0.0, setting a struct or array on a property that isn’t a nested association throws Wheels.PropertyIsIncorrectType. The app passed the login errors to create() and serialized them in an afterNew callback, which now runs too late. 369de1b adds a newEntry() method that calls SerializeJSON() before new().
A date interpolated into a where string. On 4.1.2, /admin/logs returned a 500:
invalid hexadecimal String for, [ ' ]
The controller built createdAt >= '#local.from#' from a CreateDateTime() value. CFML renders that date as {ts '2026-09-01 00:00:00'}, and on 4.1.2 the quotes inside it end the surrounding string in the where clause early. We reported it as #3941, and the fix, #3942, ships in Wheels 4.2. Until then, format the date yourself, as 09541df does:
arrayAppend(local.where, "createdAt >= '#DateTimeFormat(local.from, "yyyy-mm-dd HH:nn:ss")#'");
The reload password, and the CLI
Wheels 4 makes URL reloads fail closed: ?reload=true needs a non-empty reloadPassword and a matching password. The app hard-coded "changeme", the pattern @teleotic raised in D#2854. That still worked, but wheels migrate latest failed with “Invalid reload password”. The 4.1 CLI looks for WHEELS_RELOAD_PASSWORD in .env first and only falls back to a literal reloadPassword="..." in config/settings.cfm. With a value in .env and a different one in settings, the CLI sends one password and the app expects another. c45e48f gives both the same source:
set(reloadPassword=env("WHEELS_RELOAD_PASSWORD", ""));
The check’s own list
-
Plugins. 6e21dfd replaces jsconfirm with
dataConfirm=(helpers pass unknown arguments through asdata-*attributes) and one click listener, with nobuttonTo()override at all. That avoids thesuper<name>trouble @mikegrogan hit in D#3323. cb28e9c moves FlashMessagesBootstrap into aflashMessages()override inapp/views/helpers.cfmthat callssuperFlashMessages(). f09c6a9 replaces authenticateThis with app code on 4.1’sbcryptHash()andbcryptVerify(); existing$2a$hashes still verify, and pass 1’s login stopgap goes. A later post covers plugins in depth. -
Pagination. 3587d82 swaps both calls to
paginationNav()and replaces a long Bootstrap settings line withset(functionName="paginationNav", viewStyle="bootstrap4"). -
CSRF. 5f9ae56 makes the store explicit and reads the key from the environment, which silences the false positive and is right if the store ever changes:
set(csrfStore="session"); set(csrfCookieEncryptionSecretKey=env("WHEELS_CSRF_KEY", "")); -
Tests. 6afe3b4 converts one file to a WheelsTest spec and leaves the rest of the RocketUnit suite running unchanged.
assert("log.valid()")becomesexpect(log.valid()).toBeTrue(), andsetup()becomesbeforeEach().
Validation conditions
4.1.0 throws Wheels.InvalidValidationCondition for a condition or unless string it can’t parse, where 4.0.x skipped the validation. 23056c8 finds the app’s two conditions with git grep -nE 'condition\s*=|unless\s*=' -- app config. Both parse, and a new spec runs each one both ways so a future change can’t break them silently.
If you’re upgrading
- Run
wheels upgrade check --to=<your target version>before swappingvendor/wheels/, and save the output (we used--to=4.1.2). - Swap the framework, then diff
public/Application.cfcagainst the template for the version you moved to. - Run your migrations against an empty database to catch
default=''. - Look for structs or arrays assigned to plain columns, and for dates interpolated into
wherestrings. - Set
reloadPasswordfromenv("WHEELS_RELOAD_PASSWORD", "")so the app and the CLI agree. - Keep
wheels.Teston your RocketUnit base class until those tests are converted.
The finished branch boots on Lucee 7 with MySQL 8. wheels test passes 10 specs, and the RocketUnit suite runs 46 tests with 0 failures.
Previous: Pass 1: 2.x to 3.0, the layout move. Next in the series: the public/Application.cfc edits between 4.0 and 4.1.
Comments