Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions web/sites/guides/src/content/docs/v4-0-0/basics/routing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Define the URL surface of your app in `config/routes.cfm`. This page shows how t
**You'll learn:**

- How to define a single route with `get`, `post`, `patch`, `delete`
- How `resources()` produces seven REST routes in one line
- How `resources()` produces the seven REST actions in one line
- How to nest resources and namespace an admin section
- How to constrain placeholder patterns with regex
- Which helper reads which route at call time
Expand All @@ -39,7 +39,7 @@ mapper()

## Resource routes

`resources()` is where most routing happens. One call generates seven REST routes plus the named helpers that go with them.
`resources()` is where most routing happens. One call generates routes for the seven REST actions plus the named helpers that go with them.

```cfm {test:compile}
<cfscript>
Expand All @@ -51,7 +51,7 @@ mapper()
</cfscript>
```

This gives you `GET /posts`, `GET /posts/new`, `POST /posts`, `GET /posts/:key`, `GET /posts/:key/edit`, `PATCH /posts/:key`, and `DELETE /posts/:key` — plus the named routes `posts`, `newPost`, `post`, and `editPost`. `binding=true` turns on route model binding: the member actions (`show`, `edit`, `update`, `delete`) see a pre-loaded `params.post` instance without calling `findByKey` themselves.
This gives you `GET /posts`, `GET /posts/new`, `POST /posts`, `GET /posts/:key`, `GET /posts/:key/edit`, `PATCH/PUT /posts/:key` (both verbs map to `update`), and `DELETE /posts/:key` — plus the named routes `posts`, `newPost`, `post`, and `editPost`. Each route is also registered with a `.[format]` twin (`/posts.json`, `/posts/:key.xml`, …), so the actual table holds more rows than seven. `binding=true` turns on route model binding: the member actions (`show`, `edit`, `update`, `delete`) see a pre-loaded `params.post` instance without calling `findByKey` themselves.

## Trim the resource with `only` or `except`

Expand Down Expand Up @@ -88,14 +88,14 @@ The nested resource produces `POST /posts/:postKey/comments → comments##create

## Namespaced sections

Group a set of controllers under a shared URL prefix and a subfolder. `.namespace(name="admin")` prefixes URLs with `/admin` and loads controllers from `app/controllers/admin/`.
Group a set of controllers under a shared URL prefix and a subfolder. `.namespace("admin")` prefixes URLs with `/admin` and loads controllers from `app/controllers/admin/`. Declare the namespaced routes between the `.namespace()` call and a matching `.end()`:

```cfm {test:compile}
<cfscript>
mapper()
.namespace(name="admin", callback=function(map) {
map.resources("posts");
})
.namespace("admin")
.resources("posts")
.end()
.resources("posts")
.wildcard()
.end();
Expand All @@ -104,9 +104,15 @@ mapper()

URLs become `/admin/posts`, `/admin/posts/:key`, and so on; the controller class is `admin/Posts.cfc`. The outer `.resources("posts")` still serves the public `/posts` URLs — namespaces don't replace the non-namespaced routes, they sit alongside them.

<Aside type="caution">
Unlike `resources()`, `namespace()` (and `scope()`) does **not** support a `callback=` argument. Passing one is silently ignored: the routes inside the callback never register, and because the scope is never closed, every route declared afterward gets swallowed into the namespace — the public `/posts` above would 404. Always close a namespace with `.end()`. Tracked in [#3072](https://github.com/wheels-dev/wheels/issues/3072).
</Aside>

Controllers that live in `app/controllers/admin/` must extend the base controller with its full path — `extends="app.controllers.Controller"`. The bare `extends="Controller"` fails to resolve from the subfolder on Lucee (`invalid component definition, can't find component [Controller]`).

## Constrain pattern placeholders

Pattern placeholders are written as `[name]`. By default any non-slash string matches. Pass a `constraints` struct to restrict a placeholder to a regex.
Pattern placeholders are written as `[name]`. By default a placeholder matches any string without a slash or dot (`[^\./]+` — dots are reserved for the optional `.[format]` suffix). Pass a `constraints` struct to restrict a placeholder to a regex.

```cfm {test:compile}
<cfscript>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ sidebar:
order: 7
---

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 order of declarations is the order of matching — which makes routing failures easy to reason about once you know the shape.
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:**

Expand All @@ -18,13 +18,18 @@ Routing is the stage between a URL arriving and a controller method running. The

## The match algorithm

The router walks `config/routes.cfm` top-to-bottom on every request. The first route whose HTTP method and URL pattern both match the request wins — and that's the match, even if a more specific route appears later. There is no scoring, no backtracking, no "best fit."
Routes are compiled at boot into an in-memory table, and the router resolves each request in two steps:

Resource and namespaced declarations are not matched as a unit. They expand at boot time into plain routes that sit in the same ordered list as your hand-written `.get()` and `.post()` calls. A `.resources("posts")` is seven entries in the table, registered 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.
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. This is a deliberate performance index, though it contradicts the framework's older pure first-match-wins description; [#3073](https://github.com/wheels-dev/wheels/issues/3073) tracks reconciling the two.

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.

## What `.resources("posts")` expands to

One call generates seven routes. The names in the right column are what you pass to `linkTo`, `redirectTo`, and `urlFor`.
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 method | Path | Controller#action | Named route |
|-------------|------|-------------------|-------------|
Expand All @@ -42,9 +47,9 @@ Two routes can share a named route because the helpers disambiguate by HTTP verb

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

- Specific routes come before generic ones. A literal path beats a pattern with a placeholder.
- 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")` must come **before** `.resources("posts")`, or `/posts/featured` matches the `show` route with `params.key = "featured"` instead.
- 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.

```cfm title="illustrative — config/routes.cfm"
Expand Down
Loading