The Wheels router lives in vendor/wheels/Mapper.cfc with four mixin components:
| File | Purpose |
|---|---|
Mapper.cfc |
Core: initialization, regex compilation, pattern normalization, $addRoute, scope stack management |
mapper/mapping.cfc |
$draw(), end() — lifecycle and RESTful route expansion |
mapper/matching.cfc |
get(), post(), patch(), put(), delete(), root(), wildcard(), $match() — route registration |
mapper/resources.cfc |
resource(), resources(), member(), collection() — RESTful resource generation |
mapper/scoping.cfc |
scope(), namespace(), package(), controller(), constraints() — nesting and grouping |
Dispatch.cfc |
$findMatchingRoute(), $request(), $createParams() — runtime matching and dispatch |
-
Route Definition (
config/routes.cfm): Themapper()function creates aMapperinstance, returnsthisfor fluent chaining. Routes are defined via.resources(),.get(),.post(), etc. -
Scope Stack: A
scopeStackarray tracks nesting context.scope()pushes,end()pops. Resources, namespaces, and packages push scoped state onto the stack. -
Pattern Compilation:
$match()normalizes patterns, converts[variable]segments to regex via$patternToRegex(), supports optional segments(...), and glob patterns*[var]. -
Route Storage:
$addRoute()appends route structs to bothvariables.routesandapplication.wheels.routes— a flat array. -
Runtime Matching (
$findMatchingRoute): Linear scan ofapplication.wheels.routes, testing each route's regex against the request path. First match wins. -
Dispatch:
$request()calls$paramParser()which finds the matching route, merges URL/form/JSON params, and hands off to the controller.
- RESTful resources (singular and plural) with full CRUD
- Nested resources via
callbackornested=true - HTTP verb matchers: GET, POST, PUT, PATCH, DELETE
- HEAD → GET fallback
- Named routes with auto-generated camelCase names
- Namespaces and packages (controller subfolder scoping)
- Route constraints (regex per variable)
- Optional pattern segments
- Glob patterns (
*[variable]) - Shallow nesting
- Format mapping (
.[format]) - Wildcard catch-all routes
- Route redirects
_methodoverride for PUT/PATCH/DELETE from forms- JSON body parsing
- URL obfuscation support
| Feature | Wheels | Laravel | Rails | Fastify | Phoenix | ASP.NET Core |
|---|---|---|---|---|---|---|
| RESTful resources | Yes | Yes | Yes | Manual | Yes | Yes |
| Named routes | Yes (auto) | Yes | Yes (auto) | Plugin | Yes (verified) | Yes |
| Nested resources | Yes | Yes | Yes | Manual | Yes | Yes |
| Route constraints | Regex only | Regex + typed | Regex + custom class | JSON Schema + custom | No (controller-level) | Typed + chained + custom |
| Route groups/scoping | namespace, package, scope | group() with attributes | namespace, scope, concerns | register() + prefix | scope + pipe_through | MapGroup() |
| Middleware at route level | No (controller filters only) | Yes | Constraints + controller filters | Yes (lifecycle hooks) | Yes (pipelines per scope) | Yes (endpoint filters) |
| API versioning | Manual namespace | Route prefix groups | Namespace nesting | Native Accept-Version | Nested scopes | First-class package |
| Rate limiting | No | Built-in | rack-attack gem | Plugin | Hammer lib | Built-in (4 strategies) |
| Route caching/compilation | No | artisan route:cache | Boot-time compile | Radix tree | Compile-time pattern match | DFA matcher |
| Route model binding | No | Yes (implicit + explicit) | No (controller-level) | No | No | Via filters |
| Health check routes | No | Manual | Built-in (7.1+) | Plugin | Manual | First-class |
| Subdomain routing | No | Route::domain() | constraints subdomain: | Native host constraint | Custom Plug | [Host()] attribute |
| Fallback routes | wildcard() | Route::fallback() | match via: :all | setNotFoundHandler | No | app.MapFallback() |
| Route listing/debugging | Route tester GUI | artisan route:list | bin/rails routes | --routes flag | mix phx.routes | dotnet-routes tool |
| Typed constraints | No | whereNumber(), whereAlpha() | No | Built-in | No | :int, :guid, :bool, etc. |
| Route-level redirect | Yes | Yes | Yes | Yes | No | Yes |
Current: $findMatchingRoute() does O(n) linear scan with regex matching per route.
Modern: Fastify uses a radix tree (O(log n)), ASP.NET Core uses a DFA, Phoenix compiles to pattern matching.
Impact: Applications with 100+ routes see measurable latency. Every request pays the cost.
Current: Middleware-like behavior requires controller filters. No way to attach middleware to route groups at the routing layer. Modern: Every major framework supports attaching middleware/pipelines at the route or group level. Impact: Cross-cutting concerns like auth, CORS, and rate limiting must be duplicated across controllers.
Current: namespace() adds both URL prefix AND controller package. package() adds only controller package. No way to group routes sharing middleware, constraints, or other attributes without side effects.
Modern: Laravel Route::group(), Rails scope, Phoenix scope + pipe_through all support attribute-only grouping.
Impact: Developers cannot create logical groupings like "all API routes share these constraints and this middleware."
Current: Only raw regex via constraints: { id: "\d+" }.
Modern: Laravel has whereNumber(), whereAlpha(), whereUuid(). ASP.NET has :int, :guid, :bool, :min(n).
Impact: More error-prone, verbose constraint definitions.
Current: Routes are re-registered on every application start. Regex compiled per route on first match. Modern: Laravel compiles routes to a cached file. Phoenix compiles at compile-time. Impact: Slow app startup with many routes; regex compilation happens at runtime.
Current: Must manually use namespace("api").namespace("v1")... nesting.
Modern: Fastify has native Accept-Version header support. ASP.NET has a dedicated versioning package.
Impact: No standardized API versioning pattern.
Current: Must define manually. Modern: Rails 7.1+ and ASP.NET Core have built-in health check routes for Kubernetes probes. Impact: Operational friction for containerized deployments.
Current: Not supported. Modern: Laravel, Rails, Fastify, and ASP.NET all support subdomain-based routing. Impact: Multi-tenant apps require workarounds.
A true grouping method that doesn't imply namespacing or packaging — just shared attributes.
// New: group routes sharing middleware, prefix, or constraints
mapper()
.group(path="api", constraints={format: "json"}, callback=function(map) {
map.resources("users")
map.resources("posts")
})
.end()Implementation: Add a group() method to scoping.cfc that delegates to scope() without implying package or namespace semantics.
Convenience methods on the mapper for common constraint patterns.
// New convenience methods
.get(name="user", pattern="users/[id]", to="users##show")
.whereNumber("id")
.whereAlpha("slug")
.whereAlphaNumeric("token")
.whereUuid("guid")
.whereIn("status", "active,inactive,pending")Implementation: Add chainable constraint methods to matching.cfc that set regex constraints on the last-registered route.
mapper()
.health() // GET /health -> returns {status: "ok", timestamp: ...}
// or
.health(to="monitoring##check") // custom handler
.end()Implementation: Add to matching.cfc as a specialized get() call with a built-in default handler.
mapper()
.api(callback=function(api) {
api.version(1, callback=function(v1) {
v1.resources("users")
})
api.version(2, callback=function(v2) {
v2.resources("users")
})
})
.end()
// Generates: /api/v1/users, /api/v2/usersImplementation: Add api() and version() methods that combine namespace + path prefix.
// CLI or programmatic route dumping
mapper.getRoutes() // already exists
// Add formatted output
mapper.$listRoutes() // returns formatted table of all routesImplementation: Enhance existing getRoutes() with a formatted output option.
Current: $findMatchingRoute() lazily compiles regex on first match.
Fix: Compile all regex in $addRoute(). The $compileRegex call validates the regex at registration time rather than at first match. Note: the compiled Java Pattern object is NOT stored on the route struct because Duplicate() (used at match time) cannot reliably deep-copy Java objects across all CFML engines.
// In $addRoute: validate regex compiles correctly (do not store the Java object)
$compileRegex(argumentCollection = arguments);Create a map from HTTP method → routes for that method, reducing the search space.
// In $addRoute: index by method
if (StructKeyExists(arguments, "methods")) {
for (method in ListToArray(arguments.methods)) {
if (!StructKeyExists(variables.routeIndex, method)) {
variables.routeIndex[method] = [];
}
ArrayAppend(variables.routeIndex[method], arguments);
}
}Then $findMatchingRoute() only scans routes for the current HTTP method.
Many routes have no variables (e.g., /login, /about). These can be exact-matched via a hash map in O(1) before falling back to regex scanning.
// In $addRoute: detect static routes
if (!Find("[", arguments.pattern)) {
variables.staticRoutes[arguments.methods & ":" & arguments.pattern] = arguments;
}
// In $findMatchingRoute: check static routes first
local.staticKey = arguments.requestMethod & ":/" & arguments.path;
if (StructKeyExists(variables.staticRoutes, local.staticKey)) {
return Duplicate(variables.staticRoutes[local.staticKey]);
}Allow attaching middleware functions or component names at the route or group level.
mapper()
.group(middleware="authenticate", callback=function(map) {
map.resources("posts")
map.group(middleware="requireAdmin", callback=function(admin) {
admin.resources("users")
})
})
.end()Implementation: Store middleware in the route struct. During dispatch, invoke middleware chain before calling the controller action. This would require changes to Dispatch.cfc.
Auto-resolve route parameters to model instances, similar to Laravel.
.resources("users") // params.key auto-resolves to User model instance
// Or explicit binding
.get(name="user", pattern="users/[user]", to="users##show")
.bind("user", "User") // [user] param resolved via model("User").findByKey()Implementation: After route matching in $createParams(), check for bindings and resolve model instances.
Replace linear scan with a trie/radix tree for O(log n) matching. This is the approach used by Fastify's find-my-way and ASP.NET Core's DFA matcher.
Complexity: High. Would require a complete rewrite of $findMatchingRoute() and a tree-building step after route registration.
Recommendation: Only pursue if route counts regularly exceed ~200. The method-indexed + static fast path from Phase 2 handles most real-world cases.
| Change | Impact | Effort | Breaking? | Phase |
|---|---|---|---|---|
group() method |
High | Low | No | 1 |
| Typed constraints | Medium | Low | No | 1 |
health() route |
Medium | Low | No | 1 |
| API versioning helpers | Medium | Low | No | 1 |
| Route listing | Low | Low | No | 1 |
| Pre-compile regex | Medium | Low | No | 2 |
| Method-indexed lookup | High | Medium | No | 2 |
| Static route fast path | Medium | Medium | No | 2 |
| Route-level middleware | High | High | Yes* | 3 |
| Route model binding | Medium | Medium | No | 3 |
| Radix tree router | High | Very High | Yes* | 3 |
*Phase 3 items are additive and backward-compatible if implemented carefully, but they change dispatch behavior.
Implement Phase 1 + Phase 2 together. These are all non-breaking additions that modernize the router's DX and performance without disrupting existing applications. The combined changes would bring Wheels routing significantly closer to Laravel/Rails quality while maintaining full backward compatibility.
The group() method and typed constraints alone address the two most frequent complaints from developers coming from other frameworks. The performance optimizations (method indexing + static fast path) can reduce route matching time by 50-80% for typical applications.
Phase 3 items (especially route-level middleware) should be considered for a major version release since they represent a philosophical shift in how middleware is managed.