Skip to content

Core Concepts

How Routing Works

Routing is the stage between a URL arriving and a controller method running. The algorithm is small enough to keep in your head, the expansion rules are fixed, and the precedence rules are mechanical — static paths first, then declaration order — which makes routing failures easy to reason about once you know the shape.

You’ll learn:

  • The match algorithm the router uses on every request
  • What .resources("posts") actually generates
  • The order rules that decide which route wins
  • How route model binding loads records before the action runs
  • Why named routes are the right way to build URLs

Routes are compiled at boot into an in-memory table, and the router resolves each request in two steps:

  1. Static routes first. Routes whose patterns contain no placeholders (/posts/featured, /about) live in an exact-path index and are checked with an O(1) lookup before anything else. A literal path always beats a placeholder route, no matter where either was declared. If two routes register the same static pattern, the first one declared wins.
  2. Placeholder routes in declaration order. Everything that isn’t a static match falls through to an ordered top-to-bottom scan of the placeholder routes. The first route whose HTTP method and URL pattern both match wins — even if a more specific placeholder route appears later. There is no scoring, no backtracking, no “best fit.”

The static-first step means declaration order does not decide static-vs-placeholder conflicts — only placeholder-vs-placeholder ones and ties between identical static patterns. These are the intended semantics, settled in #3073 and pinned by the framework test suite: a literal path always routes to its literal route, so the classic bug where an earlier /posts/:key steals /posts/featured cannot happen in Wheels.

Resource and namespaced declarations are not matched as a unit. They expand at boot time into plain routes that sit in the same table as your hand-written .get() and .post() calls. A .resources("posts") registers an entry per REST action, in a fixed order. The wildcard route catches anything that falls through and maps /controller/action conventionally — a legacy pattern kept for upgrade paths. Named routes are the preferred way to wire URLs.

One call generates the seven REST actions below. Each row is also registered with a .[format] twin (/posts.json, /posts/:key.xml, …) for content negotiation, so the actual table holds twice as many rows. The names in the right column are what you pass to linkTo, redirectTo, and urlFor.

HTTP methodPathController#actionNamed route
GET/postsposts#indexposts
GET/posts/newposts#newnewPost
POST/postsposts#create(same name posts)
GET/posts/:keyposts#showpost
GET/posts/:key/editposts#editeditPost
PATCH/PUT/posts/:keyposts#update(same name post)
DELETE/posts/:keyposts#delete(same name post)

Two routes can share a named route because the helpers disambiguate by HTTP verb. linkTo(route="post", key=post.id) renders a GET link to show; buttonTo(route="post", key=post.id, method="delete") renders a DELETE button against the same name. You look up by name, the helper picks the verb.

Route order is almost always the cause when a URL matches the wrong action. Four rules cover every case:

  • A literal path beats a pattern with a placeholder — this is enforced by the router itself (the static-route index resolves first), not a convention you have to maintain by ordering.
  • .resources(...) declarations come before .root(...), which comes before .wildcard(). The wildcard is always last.
  • A custom .get(pattern="/posts/featured", to="posts##featured") routes /posts/featured to featured whether it’s declared before or after .resources("posts") — the static index wins over the placeholder show route in both orders. Declaring it first is still good style: it keeps the intent visible and matches the placeholder-vs-placeholder rule, where order genuinely decides.
  • Order within a named-route group doesn’t matter for URL generation. Named routes are a keyed lookup — the name is the key, not the position.
illustrative — config/routes.cfm
mapper()
.get(pattern="/posts/featured", to="posts##featured")
.resources("posts")
.root(to="home##index", method="get")
.wildcard()
.end();

With .resources(name="posts", binding=true) — or globally via set(routeModelBinding=true) in config/settings.cfm — the router loads params.post from the database before your action runs. The convention is controller posts → model Postparams.post; override with binding="BlogPost" when the controller name doesn’t singularize cleanly to the model. The helper runs after route dispatch but before beforeAction filters, so filters and actions both see the resolved instance.

Member actions (show, edit, update, delete) see a pre-populated params.post ready to use. If the record doesn’t exist, the dispatcher raises Wheels.RecordNotFound and renders a 404 — the controller action never runs. Without binding=true, params.post is undefined and you’re expected to call findByKey(params.key) yourself; in development mode the framework logs a warning at dispatch time when a binding-eligible action is missing its binding.

Named routes are the one place URLs for a resource are defined, and every helper reads from that same table:

  • linkTo(route="post", key=post.id, text="View")
  • redirectTo(route="posts")
  • urlFor(route="newPost")
  • buttonTo(route="post", key=post.id, method="delete")
  • startFormTag(route="post", key=post.id, method="put")

Rename the resource once in config/routes.cfm and every caller updates. That is the whole reason to route by name rather than by hand-written URL string.