Upgrading
Upgrading from 3.x to 4.0
This guide walks every breaking change between Wheels 3.x and 4.0, with before/after code for each. Most 3.x apps upgrade in an afternoon. The pressure points are CORS defaults, the test base class rename, the plugins/ → packages split (packages are now standalone repos installed into vendor/), the wheels CLI command renames, and Vite manifest strictness (production now throws on missing entries — rebuild assets during the upgrade window). Everything else is either additive or continues to work with a deprecation warning.
You’ll learn:
- Every breaking default and rename in 4.0, with the source PR or
CHANGELOG.mdentry - How to port a 3.x plugin to the new package activation model
- How to move your tests from
wheels.Test(RocketUnit) towheels.WheelsTest(BDD) - What to verify after the upgrade and where to look when something misbehaves
Pre-upgrade checklist
Section titled “Pre-upgrade checklist”-
Pin your CFML engine versions in
.cfconfig.jsonor your container image. 4.0 supports Lucee 5/6/7, Adobe ColdFusion 2018/2021/2023/2025, and BoxLang — the reference platform is Lucee 7 + SQLite. -
Take a database snapshot. The migrator’s
migrateTo()behavior was tightened in 4.0 to detect previously-skipped migrations in a range (CHANGELOG.md→ Fixed, #1928). -
Confirm your 3.x test suite is green. If it isn’t, upgrading will mix real breakage with pre-existing failures.
-
Read the audit and comparison docs for context:
docs/releases/wheels-3.0-vs-4.0.mdanddocs/releases/wheels-4.0-audit.md. -
Replace
vendor/wheels/with the 4.0 source. If you vendor the framework,git checkoutthe 4.0 tag. If you clone-and-run, pull.
The eleven breaking changes
Section titled “The eleven breaking changes”Each item cites its CHANGELOG.md “Changed” or “Removed” entry. Where the skeleton blog post calls out a two-path upgrade, Path A (fix directly) is documented here; Path B (the Legacy Compatibility Adapter, #2015) is covered at the end.
1. CORS default changed from wildcard to deny-all
Section titled “1. CORS default changed from wildcard to deny-all”CHANGELOG: Breaking: CORS middleware default changed from wildcard * to deny-all (#2039).
In 3.x, the CORS middleware defaulted to allowOrigins="*". In 4.0, there is no default — you must configure allowOrigins explicitly or requests are rejected.
// 3.x — no configuration needed; wildcard was the defaultset(middleware = [ new wheels.middleware.Cors() ]);// 4.0 — allowOrigins is requiredset(middleware = [ new wheels.middleware.Cors(allowOrigins="https://myapp.com,https://admin.myapp.com")]);Related hardening: wildcard-plus-credentials combinations are now rejected (#2053).
CORS — two paths in 4.0
Section titled “CORS — two paths in 4.0”The change above only affects apps that configured CORS via new wheels.middleware.Cors(). If your app used the global setting instead, none of that applies — the setting is still honored in 4.0, and no migration is required.
// 3.x global setting — still honored in 4.0, no changes neededset(allowCorsRequests = true);set(accessControlAllowOrigin = "https://myapp.com");set(accessControlAllowMethods = "GET, POST, PUT, DELETE, OPTIONS");set(accessControlAllowHeaders = "Origin, Content-Type, X-Auth-Token, X-Requested-By, X-Requested-With");set(accessControlAllowCredentials = false);set(accessControlAllowMethodsByRoute = false);All six CORS settings (allowCorsRequests and the five accessControlAllow* settings) are initialized in the 4.0 security defaults and read on every request. The only thing that changed is the middleware’s default — not the global setting.
How the two paths differ
Global setting (set(allowCorsRequests=true)) | wheels.middleware.Cors | |
|---|---|---|
| Where it runs | onRequestStart event — before dispatch | Middleware pipeline — during dispatch |
| OPTIONS preflight | Handles via abort in onRequestStart | Returns empty 200 and short-circuits dispatch |
| Per-route scoping | No | Yes |
Access-Control-Max-Age | No | Yes |
| Available since | Wheels 2.x | Wheels 3.0 |
What happens when both are active
The two paths used to be independent and additive — both layers emitted their own Access-Control-Allow-* lines (cfheader stacks rather than replaces), and an origin allowed by both layers — exactly the origins a migrating app cares about — received duplicate Access-Control-Allow-Origin headers, which the Fetch spec makes browsers reject (#3114). curl looked fine; browsers failed the CORS check.
As of the #3114 fix, Wheels arbitrates this automatically. When a wheels.middleware.Cors instance is registered in the pipeline, the global onRequestStart path detects it and steps aside — skipping both $setCORSHeaders and the global OPTIONS abort — so the middleware is the single source of truth for CORS headers and preflight on every request type. A one-time warning is written to wheels.log prompting you to clean up the now-redundant setting.
One caveat: the middleware pipeline does not run on the public root/congrats page (a bare .root(method="get") route). Under the old additive behavior that page carried the global headers; now that the global path defers whenever the middleware is registered, it gets no CORS headers at all. If you need CORS on a route the pipeline doesn’t cover, use the global setting alone (without the middleware).
Even with the arbitration in place, do not run both deliberately — it is a safety net for the migration window, not a supported configuration. The moment you configure the Cors middleware, remove set(allowCorsRequests=true) (and the accessControlAllow* settings) in the same change.
Recommendation
- Apps using the global setting: leave it. It works in 4.0 without changes.
- Apps using the middleware: add
allowOriginsexplicitly (see the breaking change above) and make sure the global setting is off. - New CORS configuration: prefer the middleware — it supports per-route scoping and has a secure deny-all default.
Migrating from global settings to the middleware
Section titled “Migrating from global settings to the middleware”If you follow the recommendation above and move from set(accessControlAllow*) global settings to the wheels.middleware.Cors constructor, be aware that the allow-list defaults are not a like-for-like match.
| Value | Legacy global setting default | Cors constructor default |
|---|---|---|
Methods (accessControlAllowMethods / allowMethods) | "GET, POST, PATCH, PUT, DELETE, OPTIONS" | "GET,POST,PUT,PATCH,DELETE,OPTIONS" |
Headers (accessControlAllowHeaders / allowHeaders) | "Origin, Content-Type, X-Auth-Token, X-Requested-By, X-Requested-With" | "Content-Type,Authorization,X-Requested-With" |
The middleware default drops X-Auth-Token, X-Requested-By, and Origin from the header allow-list. A like-for-like swap silently shrinks the list: preflight OPTIONS requests from clients that send those headers receive an Access-Control-Allow-Headers response that omits those headers, and the browser blocks the real request with no entry in your server logs.
Pass the missing headers explicitly when constructing the middleware:
set(middleware = [ new wheels.middleware.Cors( allowOrigins = "https://myapp.com", allowHeaders = "Content-Type,Authorization,X-Requested-With,X-Auth-Token,X-Requested-By,Origin" )]);The methods difference (spaces vs no spaces between list items) is cosmetic — HTTP implementations trim list values. No action is needed unless you pattern-match the exact string.
2. HSTS defaults on in production
Section titled “2. HSTS defaults on in production”CHANGELOG: Breaking: HSTS header defaults on in production (#2081).
The SecurityHeaders middleware (#2036) emits Strict-Transport-Security: max-age=31536000; includeSubDomains by default in the production environment. To suppress HSTS emission from the app (e.g., when your load balancer already sets it), pass hsts=false to the middleware constructor (#2174). deployment/security-hardening.mdx covers the full set of configuration knobs.
3. CSRF key required in production; JWT algorithm validated
Section titled “3. CSRF key required in production; JWT algorithm validated”CHANGELOG: Breaking: Reload password must be non-empty for environment switching in production (#2082) and the Security section item for CSRF key enforced in production (#2079).
This item applies only when you store CSRF tokens in cookies (set(csrfStore="cookie")) — the default store is session, which needs no key. With the cookie store, an empty key is auto-generated outside production (#2054) with a wheels_security warning, but cookies rotate on every deploy when that happens; in production the framework throws Wheels.Security.MissingCsrfKey instead of auto-generating. Set a stable key.
The setting name is csrfCookieEncryptionSecretKey — that is the only name the framework reads:
set(csrfCookieEncryptionSecretKey = env("WHEELS_CSRF_KEY"));JWT verification now validates the alg claim and uses constant-time signature comparison (#2079, #2086). Tokens forged with alg: none or mismatched algorithms are rejected.
4. allowEnvironmentSwitchViaUrl defaults to false in production
Section titled “4. allowEnvironmentSwitchViaUrl defaults to false in production”CHANGELOG: Breaking: allowEnvironmentSwitchViaUrl defaults to false in production (#2076) and the reload-password requirement (#2082).
In 3.x, ?environment=production on a URL could switch the running environment. In 4.0, that is off by default in production and the reload password must be non-empty. Re-enable only for controlled staging environments.
The reload password is populated only by set(reloadPassword = ...) in config/settings.cfm. A value in .env alone is not wired into framework settings automatically — you must call set() explicitly:
// env() reads from .env (or OS environment); the empty-string fallback// preserves the fail-closed behavior when the variable is absent.set(reloadPassword = env("WHEELS_RELOAD_PASSWORD", ""));Without this line, every boot will log the following warning even if reloadPassword is present in .env:
WARN Wheels: reloadPassword is empty — URL-based environment switching and application reload are disabled until a password is set in config/settings.cfmThe env() helper is the canonical pattern for flowing .env values into framework settings. Use the same approach for csrfCookieEncryptionSecretKey and other security settings.
config/environment.cfm has the same load-order gap
Section titled “config/environment.cfm has the same load-order gap”wheels new scaffolds a hardcoded set(environment="development") in config/environment.cfm, so this gap does not affect freshly generated apps. If you wired the running environment from .env by hand — for example, set(environment=application.env.environment) — the same load-order issue applies:
// application.env may not be populated before this file runs.set(environment=application.env.environment);When application.env is not yet initialized, the expression resolves to "". The set() call either errors or silently leaves the environment at the wrong value. In production this commonly manifests as servers reporting environment=development to Sentry, the debug bar, and anywhere get("environment") is consumed — despite .env containing environment=production.
Use env() directly:
// env() reads from .env (or OS environment) without going through application.env.// Default "production" is intentional: a missing key fails safe to prod, not dev.set(environment=env("environment", "production"));The "production" default is deliberate: a misconfigured or missing environment key in .env resolves to the safer environment rather than development, where debug output, verbose errors, and a lower security posture would be active on a live server.
5. RateLimiter defaults hardened
Section titled “5. RateLimiter defaults hardened”CHANGELOG: Breaking: RateLimiter trustProxy default changed from true to false (#2024); Breaking: RateLimiter proxy strategy default changed to last (#2088).
If your app sits behind a proxy or load balancer, configure both flags explicitly — do not rely on the 3.x dev defaults.
set(middleware = [ new wheels.middleware.RateLimiter( maxRequests = 100, windowSeconds = 60, trustProxy = true, proxyStrategy = "last" )]);Rate limiter also now fails closed on lock timeout (#2069) rather than fail-open.
6. CSRF cookie sets SameSite
Section titled “6. CSRF cookie sets SameSite”CHANGELOG: Breaking: CSRF cookie now sets SameSite attribute (#2035).
Cross-site POSTs from third-party frames that relied on the missing attribute will break. Same-site app flows are unaffected.
7. wheels snippets renamed to wheels generate snippets
Section titled “7. wheels snippets renamed to wheels generate snippets”CHANGELOG: Breaking: wheels snippets CLI command renamed to wheels generate snippets (#1852).
Update any scripts, CI jobs, or IDE integrations that shell out to wheels snippets. The flag surface is otherwise unchanged.
8. Test base class renamed: wheels.Test → wheels.WheelsTest
Section titled “8. Test base class renamed: wheels.Test → wheels.WheelsTest”CHANGELOG: Breaking: Test base class namespace renamed (#1889) and Deprecated: wheels.Test test base class (#1889).
The RocketUnit-era base (wheels.Test) still loads during 4.0, but all new tests extend wheels.WheelsTest and use BDD syntax.
component extends="wheels.Test" { function test_user_requires_email() { var u = model("User").new(); assert("NOT u.valid()"); }}component extends="wheels.WheelsTest" { function run() { describe("User", () => { it("requires an email", () => { var u = model("User").new(); expect(u.valid()).toBeFalse(); }); }); }}See the Testing guide for matchers and the phantom-matcher list.
There is also a wheels.Testbox shim — a pure alias of wheels.WheelsTest retained for specs written before the rename. It adds no behavior. Deprecated since 4.0 with a removal target of 5.0; both it and wheels.Test should migrate to wheels.WheelsTest. Note: the released 4.0.3 wheels upgrade check only flags extends="wheels.Test"; the combined rule that also flags extends="wheels.Testbox" lives on develop and ships in a post-4.0.3 release.
9. Tests directory renamed: tests/specs/functions/ → tests/specs/functional/
Section titled “9. Tests directory renamed: tests/specs/functions/ → tests/specs/functional/”CHANGELOG: Breaking: Tests directory tests/specs/functions/ renamed to tests/specs/functional/ (#1872).
Rename the directory. No code changes required.
10. application.wirebox renamed to application.wheelsdi
Section titled “10. application.wirebox renamed to application.wheelsdi”CHANGELOG: Breaking: application.wirebox renamed to application.wheelsdi (#1888).
The DI container moved in-house (CHANGELOG.md → Internal rim modernized: WireBox/TestBox replaced, #1883). The surface is compatible, but code that reached into application.wirebox directly must be updated.
// 3.xvar svc = application.wirebox.getInstance("emailService");// 4.0var svc = application.wheelsdi.getInstance("emailService");// 4.0, preferredvar svc = service("emailService");service() is the new global helper (#1933); prefer it over reaching into the container.
11. Vite manifest strictness — missing entries throw in production
Section titled “11. Vite manifest strictness — missing entries throw in production”CHANGELOG: Breaking: viteStrictManifest defaults to true (#2133).
In 3.x, a missing entry in public/assets/.vite/manifest.json silently fell back to the raw source path. In 4.0, the default flips: viteScriptTag(), viteStyleTag(), and vitePreloadTag() throw Wheels.ViteAssetNotFound in production when the manifest doesn’t contain the requested entrypoint. This catches stale-build deploys at first request instead of letting them ship broken <script> tags to the browser.
The most common failure mode is a deploy that pushes new CFML code without rebuilding the Vite bundle — the manifest on the server is older than the entrypoint referenced in a view.
Recommended fix — rebuild assets as part of the deploy:
npm run build # produces public/assets/.vite/manifest.jsonEscape hatch — restore 3.x silent-fallback behavior:
set(viteStrictManifest = false);Use the escape hatch only if you can’t rebuild assets during the upgrade window — then flip it back on once your deploy pipeline emits the manifest reliably.
Deprecations you should plan to address
Section titled “Deprecations you should plan to address”Plugins → packages
Section titled “Plugins → packages”CHANGELOG: Deprecated: Legacy plugins/ folder — superseded by the new packages/ → vendor/ activation model. Plugins still load, with a deprecation warning. (#1995).
The plugins/ directory still works. It emits a warning at load. Plan a migration — here’s the shape of porting a 3.x plugin called AuditLog into its own package repo:
-
Create a package repo. The main CFC’s filename matches the directory name. You can start locally and publish to GitHub later (or host privately).
package layout wheels-auditlog/package.jsonAuditLog.cfc -
Write the
package.jsonmanifest.provides.mixinsis opt-in; 3.x plugins defaulted toglobal, which is why provenance was hard to trace.wheels-auditlog/package.json {"name": "wheels-auditlog","version": "1.0.0","description": "Writes a row to audit_log for every mutating action","wheelsVersion": ">=4.0","provides": {"mixins": "controller"}} -
Port the plugin CFC. Rename lifecycle hooks if you used them —
init,register,boot,onPluginLoad,onPluginActivateare reserved and never mixed in.wheels-auditlog/AuditLog.cfc component output="false" {public any function init() {return this;}public void function recordAudit(required string action, required numeric recordId) {model("AuditEntry").create(action = arguments.action,recordId = arguments.recordId,userId = session.userId ?: 0);}} -
Install into
vendor/, then reload:your shell wheels packages add wheels-auditlogwheels reload -
Delete
plugins/auditlog/. The deprecation warning goes away.
See Packages for manifest fields, per-method mixin overrides via mixin="model" or mixin="none" annotations, and error isolation. If you need a staged migration, the wheels-legacy-adapter package ships deprecation logging and API shims for 3.x patterns.
Monolithic paginationLinks()
Section titled “Monolithic paginationLinks()”CHANGELOG: composable pagination helpers added (#1930). The 3.x paginationLinks() is retained but deprecated. As of #2727, calling paginationLinks() emits a one-time per-request WriteLog(type="warning") pointing at the replacement so apps see the signal without flooding the log. wheels upgrade check --to=4.0.0 also greps app/views/ for paginationLinks( and flags every hit with a remediation pointer. New code should use paginationNav() or compose the individual helpers (paginationInfo, firstPageLink, previousPageLink, pageNumberLinks, nextPageLink, lastPageLink).
RocketUnit test style
Section titled “RocketUnit test style”CHANGELOG: Deprecated: RocketUnit test style for new tests (#1925) and Removed: Legacy RocketUnit core test scaffolding (#1925). Existing app specs still run; the framework-level RocketUnit runner is gone. Do not write new tests in RocketUnit style.
In-dev-server HTTP MCP endpoint
Section titled “In-dev-server HTTP MCP endpoint”CHANGELOG: Deprecated: In-dev-server HTTP MCP endpoint at /wheels/mcp — superseded by the LuCLI stdio MCP server (wheels mcp wheels).
There is no wheels mcp setup command — migrate by adding the stdio server to your .mcp.json manually:
{"mcpServers": {"wheels": {"command": "wheels", "args": ["mcp", "wheels"]}}}Note that bare wheels mcp does not print this snippet on the released launcher (it errors with “missing module name”); see the MCP integration guide for editor-specific variants.
Legacy CommandBox wheels-cli module
Section titled “Legacy CommandBox wheels-cli module”CHANGELOG: Deprecated: Legacy CommandBox wheels-cli module — superseded by the new wheels CLI. Scheduled for removal in v5.0. (#2227, #2634)
The CommandBox-based wheels-cli module (invoked as box wheels upgrade, box wheels generate, etc.) is deprecated and does not know about Wheels 4.0+. Running box wheels upgrade now prints a deprecation banner and exits immediately instead of silently reporting “You are already on the latest version.” Use the new Wheels CLI for upgrade checks:
brew install wheels-dev/wheels/wheelswheels upgrade check # scan for breaking changeswheels upgrade apply # swap vendor/wheels/ from the CLI bundle (creates backup)Homebrew 5.1+ asks you to trust third-party taps on first use — run brew trust wheels-dev/wheels once if prompted.
Browser-test fixture routes (/_browser/*) moved into the framework
Section titled “Browser-test fixture routes (/_browser/*) moved into the framework”CHANGELOG: Fixed: Framework-internal browser-test fixture controllers, views, and /_browser/* routes no longer leak into application-level files. (#2135, #2138)
The /_browser/home, /_browser/login, /_browser/dashboard, /_browser/login-as, and related fixture routes used by Wheels’ browser-testing DSL (wheels.wheelstest.BrowserTest) used to live in application-level files — config/routes.cfm plus app/controllers/BrowserTest*.cfc and app/views/browsertest*/. As of 4.0 they ship as framework internals under vendor/wheels/public/browser-fixtures/ and are auto-mounted by the framework’s own route loader.
What you need to do depends on whether your app relied on these routes:
- You never used
/_browser/*yourself — no action. These routes were only ever meaningful when you ran Wheels’ own browser specs against the dev server, and Wheels-generated app scaffolds (wheels new) never included them. Yourconfig/routes.cfmstays as-is. - You ran Wheels browser specs against a running dev server — opt in by adding
set(loadBrowserTestFixtures=true);toconfig/settings.cfm(or to yourtesting/developmentenv override). The framework then mounts the/_browser/*routes automatically. The setting defaults tofalseand production/maintenance/staging environments always skip the mount. - You defined your own
/_browser/*routes inconfig/routes.cfm(rare — Wheels 4.0 snapshot only) — with the fixtures enabled, your copies are dead code: the framework registers the fixture routes beforeconfig/routes.cfmis loaded, and Wheels routing is first-registration-wins, so a same-pattern app route can not override a fixture. Delete your copies. If what you actually need is a custom login flow for browser specs, useset(browserLoginAsHandler="YourController##yourAction")instead of shadowing/_browser/login-as.
Removed in 4.0
Section titled “Removed in 4.0”CHANGELOG → Removed:
- Legacy RocketUnit core test scaffolding (#1925) — framework-level runner only; app specs still load.
- Railo compatibility workaround in
$initializeMixins(#1987) — Railo is no longer a target. server.cfcfile (#1902).cli/lucli/services/MCP.cfcparallel schema registry — never wired into LuCLI’s MCP discovery; rich parameter schemas return via typed parameters onModule.cfcin a follow-up.
The Legacy Compatibility Adapter (optional soft landing)
Section titled “The Legacy Compatibility Adapter (optional soft landing)”CHANGELOG: Legacy compatibility adapter for 3.x → 4.0 migration soft-landing (#2015), now distributed as the standalone wheels-dev/wheels-legacy-adapter package.
When you can’t fix all eleven breaking items in one sprint, install the adapter, ship the upgrade, and modernize incrementally:
wheels packages add wheels-legacy-adapterwheels reloadThe adapter logs every deprecated call so you can track what’s still 3.x-shaped in production.
The adapter covers these 3.x patterns; everything else still requires manual remediation:
| Covered by the adapter | Requires manual remediation regardless |
|---|---|
renderPage() → renderView() shim | Items 1–9 and 11 (all other breaking changes) |
renderPageToString() shim | application.wirebox → application.wheelsdi (item 10) |
$legacySendEmail() argument remap | new wirebox.system.ioc.Injector(...) bootstrap code |
$legacyAppScopeGet(key) opt-in helper | |
| Migration scanner | |
| Deprecation call logger |
Recommended, not required
Section titled “Recommended, not required”These are additive in 4.0 and worth adopting during the upgrade window:
- Middleware pipeline (#1924) for cross-cutting concerns previously done with filters or plugins.
- Route model binding (#1929) —
binding=trueon resource routes auto-resolvesparams.user, cutting boilerplatefindByKey()in everyshow/edit/update. - Chainable QueryBuilder (#1922) everywhere you concatenated
WHEREstrings. - Built-in job worker (#1934) to replace external Redis-backed queues.
- HTTP test client (
TestClient, #2099) for integration tests. - Parallel test runner (#2100) for faster CI.
Post-upgrade verification
Section titled “Post-upgrade verification”-
Run the test suite.
your shell wheels test -
Run
wheels doctorto surface config and environment issues.your shell wheels doctor -
Smoke-test a form submission end-to-end. CSRF and
SameSitecookie tightening can break third-party embeds silently. -
Check the application log on first request after deploy.
PackageLoaderreports every package it loaded, every package it skipped, and why. If you ported a plugin, confirm the old entry is gone and the new one loaded. -
If you configured HSTS or CORS in code, verify the response headers with
curl -I. MisconfiguredallowOriginsreturns no CORS header at all — the browser reports it as a generic CORS failure.
Common issues
Section titled “Common issues”- “CSRF token invalid” on forms after upgrade. Either the cookie encryption key rotated (cookie store only — set
csrfCookieEncryptionSecretKeyexplicitly) or a third-party embed is hittingSameSite. See #2035, #2054, #3115. - Requests return 403 with no CORS header.
allowOriginsisn’t set. See #2039. - CORS preflight rejected after switching from global settings to the middleware. The
Corsconstructor’sallowHeadersdefault (Content-Type,Authorization,X-Requested-With) does not includeX-Auth-Token,X-Requested-By, orOrigin— all present in the legacyaccessControlAllowHeadersglobal default. Pass the missing headers explicitly. See Migrating from global settings to the middleware. - Rate limiter counts wrong behind your load balancer.
trustProxydefaults tofalsenow. See #2024, #2088. - Test runner can’t find specs. Check for
tests/specs/functions/— rename tofunctional/. See #1872. - Plugin warning at startup. Port to a package (see above) or accept the warning until you do.
- JWT tokens reject as invalid after upgrade. Algorithm validation is on. Confirm your issuer sets
algto a supported value and the signature verification path uses constant-time comparison. See #2079, #2086. Wheels.ViteAssetNotFoundthrown on first request after deploy. The deploy shipped code referencing a Vite entrypoint that isn’t in the manifest. Rebuild Vite assets (npm run build) before deploy, or temporarilyset(viteStrictManifest=false)to restore 3.x silent fallback. See #2133.- Boot fails with a “wirebox” package-not-found error. The
wireboxpackage was removed fromvendor/wheels/(#1883). Installing the legacy adapter does not restore it. Replacenew wirebox.system.ioc.Injector(...)inApplication.cfcwithnew wheels.Injector(), and replace anyapplication.wireboxreferences withapplication.wheelsdi. See item 10. - “reloadPassword is empty” boot warning despite having
reloadPasswordin.env. The reload password is populated only byset(reloadPassword = ...)inconfig/settings.cfm— a.envvalue does not flow through automatically. Addset(reloadPassword = env("WHEELS_RELOAD_PASSWORD", ""));toconfig/settings.cfm. See #2631. - Production servers report
environment=developmentdespite.envcontainingenvironment=production.application.env.environmentis not reliably populated beforeconfig/environment.cfmruns — the load-order gap means the expression resolves to""andset()picks up the wrong value. Replaceset(environment=application.env.environment)withset(environment=env("environment", "production")). The"production"default ensures a missing key fails safe to production rather than development. See #2709. - Static assets under
/miscellaneous/,/javascripts/,/stylesheets/, or/files/return 404 after upgrade. LuCLI’s bundled-defaultrewrite.configuses a narrow allow-list (images|css|js|fonts|assets|static) that excludes 3.x-conventional directory names.wheels startnow auto-provisions a project-levelrewrite.configthat covers these dirs on the first boot. If you upgraded before this fix, runwheels startonce against your upgraded project — the file is dropped automatically and existing user-authoredrewrite.configfiles are left untouched. See #2626.