Ruby on Rails Module 3 – Routing and controllers
Routing: resources and RESTful URLs
Map URLs to Rails 8.1 controller actions with resources, shallow nesting, namespaces and constraints, read bin/rails routes, and build links with helpers.
What you will learn
- Map URLs to actions with resources, singular resources, nested and shallow routes, namespaces and scopes
- Read bin/rails routes and predict the helper, verb, path and action of a route
- Order routes and add constraints so that a general route does not take a specific path
- Add member and collection routes, concerns, redirects and a root route
- Choose between path and URL helpers, and set the host that URL helpers need outside a request
Before you start
What you’ll learn · Before you start
What you will learn
- Map URLs to actions with resources, singular resources, nested and shallow routes, namespaces and scopes
- Read bin/rails routes and predict the helper, verb, path and action of a route
- Order routes and add constraints so that a general route does not take a specific path
- Add member and collection routes, concerns, redirects and a root route
- Choose between path and URL helpers, and set the host that URL helpers need outside a request
Before you start
On this page
One line of a Rails routes file, resources :batches, declared eight routes to seven actions in the table this page
records, and every request an app answers is compared with a list like that, from the top, until a route fits. Put
two lines in the wrong order and GET /reports/monthly reaches the show action with "monthly" as its id, as one of
the runs below shows.
config/routes.rb is where those decisions live. This page reads the routes file of Kaksha, the coaching-centre app
this track builds, one declaration at a time: resources and singular resources, nested and shallow routes, namespaces
and scopes, the first-match rule and constraints, member and collection routes, the routes that are not resources,
and the helpers that build every URL from the same declarations, so no view or mailer writes one by hand.
One line of resources declares seven routes
Here is Kaksha’s routes file, the one every run on this page uses:
Rails.application.routes.draw do
# The page most visitors ask for comes first.
root "batches#index"
# These two lines were written by bin/rails generate authentication.
resource :session
resources :passwords, param: :token
# Reviews hang off two resources, so the nested routes are declared once.
concern :reviewable do
resources :reviews, only: [:index, :create]
end
resources :teachers, only: [:index, :show], concerns: :reviewable
resources :batches, concerns: :reviewable do
resources :enrollments, shallow: true
end
resources :payments, only: [:index, :show] do
member do
post :refund
end
collection do
get :overdue
end
end
namespace :admin do
resources :fee_plans
end
scope "(:locale)", locale: /en|hi/ do
resources :notices, only: [:index, :show]
end
# Old links from the course pages of the first version keep working.
get "/courses/:id", to: redirect("/batches/%{id}")
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
end A resourceful route: A set of routes declared by one resources line (seven actions on four URL shapes) or one singular resource line (no index and no id), each pairing an HTTP verb and a path with a controller action. such as resources :batches declares one route for each of
seven actions, index, show, new, create, edit, update and destroy, on four URL shapes: /batches, /batches/new,
/batches/:id and /batches/:id/edit. The verb tells the shapes apart, so GET /batches lists the batches while POST
/batches creates one. The router: The part of Rails that compares a request's method and path with config/routes.rb, takes the first route that matches and calls its controller action. More in “How Rails handles a request” keeps every route in a list, in the order the file declares
them, and bin/rails routes prints that list:
# Kaksha's routes as bin/rails routes prints them. Each command boots the app once and lists the routes that
# config/routes.rb declares, in the order of the file: -c keeps the routes of one controller, -g those whose helper,
# verb, path or action contains the pattern.
require_relative "session"
Session.run(<<~SH)
cd ../kaksha
# One line, resources :batches, and the root that points at its index.
bin/rails routes -c batches
# Enrollments nested with shallow: true, and the reviews that the reviewable concern adds to two resources.
bin/rails routes -g "enrollment|review"
# The two lines bin/rails generate authentication wrote: a singular resource, and a resource keyed by a token.
bin/rails routes -g "session|password"
SH Output
$ cd ../kaksha
# One line, resources :batches, and the root that points at its index.
$ bin/rails routes -c batches
Prefix Verb URI Pattern Controller#Action
root GET / batches#index
batches GET /batches(.:format) batches#index
POST /batches(.:format) batches#create
new_batch GET /batches/new(.:format) batches#new
edit_batch GET /batches/:id/edit(.:format) batches#edit
batch GET /batches/:id(.:format) batches#show
PATCH /batches/:id(.:format) batches#update
PUT /batches/:id(.:format) batches#update
DELETE /batches/:id(.:format) batches#destroy
# Enrollments nested with shallow: true, and the reviews that the reviewable concern adds to two resources.
$ bin/rails routes -g "enrollment|review"
Prefix Verb URI Pattern Controller#Action
teacher_reviews GET /teachers/:teacher_id/reviews(.:format) reviews#index
POST /teachers/:teacher_id/reviews(.:format) reviews#create
batch_enrollments GET /batches/:batch_id/enrollments(.:format) enrollments#index
POST /batches/:batch_id/enrollments(.:format) enrollments#create
new_batch_enrollment GET /batches/:batch_id/enrollments/new(.:format) enrollments#new
edit_enrollment GET /enrollments/:id/edit(.:format) enrollments#edit
enrollment GET /enrollments/:id(.:format) enrollments#show
PATCH /enrollments/:id(.:format) enrollments#update
PUT /enrollments/:id(.:format) enrollments#update
DELETE /enrollments/:id(.:format) enrollments#destroy
batch_reviews GET /batches/:batch_id/reviews(.:format) reviews#index
POST /batches/:batch_id/reviews(.:format) reviews#create
# The two lines bin/rails generate authentication wrote: a singular resource, and a resource keyed by a token.
$ bin/rails routes -g "session|password"
Prefix Verb URI Pattern Controller#Action
new_session GET /session/new(.:format) sessions#new
edit_session GET /session/edit(.:format) sessions#edit
session GET /session(.:format) sessions#show
PATCH /session(.:format) sessions#update
PUT /session(.:format) sessions#update
DELETE /session(.:format) sessions#destroy
POST /session(.:format) sessions#create
passwords GET /passwords(.:format) passwords#index
POST /passwords(.:format) passwords#create
new_password GET /passwords/new(.:format) passwords#new
edit_password GET /passwords/:token/edit(.:format) passwords#edit
password GET /passwords/:token(.:format) passwords#show
PATCH /passwords/:token(.:format) passwords#update
PUT /passwords/:token(.:format) passwords#update
DELETE /passwords/:token(.:format) passwords#destroy
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby routes_session.rb
Read the first table column by column. The prefix is the name of the route, and adding _path or _url to it gives
its helper: batch becomes batch_path. A blank prefix shares the name of the row above it. resources :batches has
eight rows, not seven, because update answers two verbs, PATCH and PUT. The row above them is root, which also
reaches batches#index.
Write every route out
A route that reads the controller or the action out of the URL, such as get ":controller(/:action(/:id))", has
been deprecated since Rails 5.0, and on the main branch of Rails drawing one raises ArgumentError. List the routes
the app serves instead.
The third table holds the two lines that bin/rails generate authentication wrote, as the Security guide’s list of
generated files also shows. resource :session, singular, has no index and no :id segment, because a browser has one
session and /session is enough to name it. resources :passwords, param: :token names its segment :token instead of
:id, which is how a password-reset link carries its token.
Routes for actions that do not exist
The generated SessionsController defines three actions, new, create and destroy, yet resource :session declared
seven routes, show, edit and update among them. Writing resource :session, only: %i[new create destroy] makes the
table match the controller; the Routing guide adds that dropping unused routes saves memory and routing time.
Checkpoint
Check: What resources and resource declare
Nesting puts the parent in the URL, and shallow takes it out again
Every enrollment belongs to one batch, so Kaksha declares resources :enrollments inside resources :batches. The
enrollments of batch 7 then live at /batches/7/enrollments, and the action reads the batch from params[:batch_id],
taken from the URL, rather than from a hidden form field that a visitor could edit. The Routing guide’s rule of thumb
is to nest one level deep at most, because each level adds a segment to every URL and an argument to every helper.
Nested in full, all eight enrollment routes would carry the batch, although an enrollment’s own id already finds it.
shallow: true keeps the parent only where the action needs it. Step through what the option did to Kaksha’s table:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A resource inside a resource. Every enrollment belongs to one batch, so Kaksha declares resources :enrollments inside resources :batches. Nested in full, all eight enrollment routes would start with /batches/:batch_id.
- The list keeps its parent. With shallow: true, the three routes that work on the enrollments of one batch stay nested: index, new and create. The recorded table lists them under /batches/:batch_id/enrollments.
- One record drops it. The routes that act on one enrollment (show, edit, update by PATCH or PUT, destroy) need only its own id, so they move to /enrollments/:id and lose the batch_ prefix of their helpers.
- Helpers follow the URL. batch_enrollments_path(7) gave /batches/7/enrollments and enrollment_path(31) gave /enrollments/31 in the helpers run: a nested helper takes the parent, a shallow one the record alone.
- A concern shares nested routes. concern :reviewable declares reviews once, and concerns: :reviewable adds index and create under teachers and under batches, so the table lists teacher_reviews and batch_reviews.
The second table of the session above is that split, recorded: index, new and create under /batches/:batch_id/enrollments, and edit, show, update and destroy at /enrollments/:id. A shallow route: A route of a nested resource that leaves the parent out of the URL once the record has its own id, so /enrollments/31 serves show, edit, update and destroy while the list stays under its batch. like /enrollments/31 is enough for one record, and its batch is one association away.
The same table lists reviews twice, under teachers and under batches. Both come from one
routing concern: A named block of routes declared once with concern and added to several resources with concerns:, so routes that more than one resource needs, such as nested reviews, are written in one place.: concern :reviewable declares the nested reviews once, and
concerns: :reviewable adds them to each resource that should have them, so two copies of one block can never drift
apart.
Key idea
Keep the parent in the URL where an action lists or creates children, and let shallow: true give every route for one existing record its own short URL.
Checkpoint
Check: Nested and shallow routes, and concerns
namespace moves the controller too, scope moves only the path
Kaksha’s fee plans are for staff, not for students, so their routes sit inside namespace :admin. A namespace
changes three things at once: the path gains /admin, the helpers gain admin_, and the controller moves into the
Admin module. admin/fee_plans#index is Admin::FeePlansController, in app/controllers/admin/fee_plans_controller.rb,
a class of its own that a check in an Admin base controller can guard along with every other staff page.
Notices are read in English or Hindi, at /en/notices and /hi/notices, or at /notices in the default language. That is
scope "(:locale)", locale: /en|hi/: the brackets make the segment optional, the regular expression allows two
values, and nothing else moves. The helper stays notices_path, the controller stays NoticesController, and the
action reads the language from params[:locale]. The first table of the second session shows both declarations:
# The rest of Kaksha's routes file: a namespace and a scope, which move a resource to another path, and the extra
# actions that member and collection add to payments. The last command lists the redirect for old course links.
require_relative "session"
Session.run(<<~SH)
cd ../kaksha
# namespace :admin moves path, helper and controller; scope "(:locale)" moves only the path.
bin/rails routes -g "admin|notice"
# member adds an action on one payment, collection an action on the list.
bin/rails routes -g "payment|courses"
SH Output
$ cd ../kaksha
# namespace :admin moves path, helper and controller; scope "(:locale)" moves only the path.
$ bin/rails routes -g "admin|notice"
Prefix Verb URI Pattern Controller#Action
admin_fee_plans GET /admin/fee_plans(.:format) admin/fee_plans#index
POST /admin/fee_plans(.:format) admin/fee_plans#create
new_admin_fee_plan GET /admin/fee_plans/new(.:format) admin/fee_plans#new
edit_admin_fee_plan GET /admin/fee_plans/:id/edit(.:format) admin/fee_plans#edit
admin_fee_plan GET /admin/fee_plans/:id(.:format) admin/fee_plans#show
PATCH /admin/fee_plans/:id(.:format) admin/fee_plans#update
PUT /admin/fee_plans/:id(.:format) admin/fee_plans#update
DELETE /admin/fee_plans/:id(.:format) admin/fee_plans#destroy
notices GET (/:locale)/notices(.:format) notices#index {locale: /en|hi/}
notice GET (/:locale)/notices/:id(.:format) notices#show {locale: /en|hi/}
# member adds an action on one payment, collection an action on the list.
$ bin/rails routes -g "payment|courses"
Prefix Verb URI Pattern Controller#Action
refund_payment POST /payments/:id/refund(.:format) payments#refund
overdue_payments GET /payments/overdue(.:format) payments#overdue
payments GET /payments(.:format) payments#index
payment GET /payments/:id(.:format) payments#show
GET /courses/:id(.:format) redirect(301, /batches/%{id})
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby more_routes_session.rb
Side by side, the two declarations differ in exactly the columns a namespace moves:
| Declaration | Path | Helper | Controller |
|---|---|---|---|
namespace :admin |
/admin/fee_plans | admin_fee_plans_path |
Admin::FeePlansController |
scope "(:locale)" |
(/:locale)/notices | notices_path |
NoticesController |
Choosing between the two
Use a namespace when a part of the app has controllers of its own, such as staff pages behind their own check. Use a scope when only the URL changes, such as a language prefix.
Checkpoint
Check: What a namespace and a scope move
The first route that fits wins, and constraints decide what fits
The router reads its list from the top and stops at the first route whose verb, path and constraints all fit the
request. The Routing guide makes the point with a custom route written below resources: the show route above it
wins. Follow GET /reports/monthly down such a file:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The request arrives. GET /reports/monthly reaches the router. It holds the routes in the order the file declares them and takes the first one whose verb, path and constraints all fit.
- index does not fit. resources :reports declared index first, as GET /reports, and create second, which wants POST. A GET with a second segment fits neither, so the router reads on.
- new does not fit. new is GET /reports/new. Its second segment is the literal word new, and monthly is a different word.
- edit does not fit. edit is GET /reports/:id/edit. The :id segment would take monthly, but the path would then need /edit after it, and this request ends there.
- show fits, and the search stops. show is GET /reports/:id, and :id accepts any one segment. monthly fills it, so the router calls reports#show with params[:id] = "monthly", as set 1 of the run printed.
- The custom route is never read. get "reports/monthly" sits below resources, so no request for /reports/monthly ever reaches it. Set 1 also sent /reports/june to show, with id june.
- Fix 1: declare it first. Declared above resources, the custom route is compared first and fits exactly, so set 2 reached reports#monthly. /reports/7 and /reports/june still went to show.
- Fix 2: a constraint on :id. Set 3 keeps the order and adds constraints: { id: /\d+/ }. show now refuses monthly, the router reads on and the custom route fits. /reports/june fits nothing: a RoutingError, which a running app answers with 404.
The program behind the figure draws three route sets and asks each where four paths go. recognize_path answers the
way the router would, without a server:
# The router compares a request with the routes in the order the file declares them and takes the first that fits.
# Three route sets answer the same four paths: a custom route below resources, the same route above it, and the route
# below again with a constraint on :id. recognize_path asks a route set which action a path reaches; it needs no
# server, only the controller class to exist.
require "action_controller/railtie"
class ReportsController < ActionController::Base; end
class DashboardsController < ActionController::Base; end
def router(&routes)
set = ActionDispatch::Routing::RouteSet.new
set.draw(&routes)
set
end
def reach(set, url)
set.recognize_path(url).map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
rescue ActionController::RoutingError => error
"#{error.class.name.demodulize}: #{error.message}"
end
sets = {
"1. get 'reports/monthly' below resources :reports" => router do
resources :reports
get "reports/monthly", to: "reports#monthly"
end,
"2. get 'reports/monthly' above resources :reports" => router do
get "reports/monthly", to: "reports#monthly"
resources :reports
end,
"3. below again, with constraints: { id: /\\d+/ }" => router do
resources :reports, constraints: { id: /\d+/ }
get "reports/monthly", to: "reports#monthly"
end
}
sets.each do |name, set|
puts name
%w[/reports/monthly /reports/7 /reports/new /reports/june].each do |path|
puts " GET #{path.ljust(16)} -> #{reach(set, path)}"
end
end
# A lambda constraint receives the whole request, so it can test what a path cannot show, such as the host.
centres = router do
get "dashboard", to: "dashboards#show", constraints: ->(request) { request.subdomain.present? }
end
puts "4. a lambda constraint: only a centre's own subdomain"
%w[http://pune.kaksha.example/dashboard http://kaksha.example/dashboard].each do |url|
puts " GET #{url.ljust(37)} -> #{reach(centres, url)}"
end Output
1. get 'reports/monthly' below resources :reports
GET /reports/monthly -> controller: "reports", action: "show", id: "monthly"
GET /reports/7 -> controller: "reports", action: "show", id: "7"
GET /reports/new -> controller: "reports", action: "new"
GET /reports/june -> controller: "reports", action: "show", id: "june"
2. get 'reports/monthly' above resources :reports
GET /reports/monthly -> controller: "reports", action: "monthly"
GET /reports/7 -> controller: "reports", action: "show", id: "7"
GET /reports/new -> controller: "reports", action: "new"
GET /reports/june -> controller: "reports", action: "show", id: "june"
3. below again, with constraints: { id: /\d+/ }
GET /reports/monthly -> controller: "reports", action: "monthly"
GET /reports/7 -> controller: "reports", action: "show", id: "7"
GET /reports/new -> controller: "reports", action: "new"
GET /reports/june -> RoutingError: No route matches "/reports/june"
4. a lambda constraint: only a centre's own subdomain
GET http://pune.kaksha.example/dashboard -> controller: "dashboards", action: "show"
GET http://kaksha.example/dashboard -> RoutingError: No route matches "http://kaksha.example/dashboard"
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby first_match.rb
Set 1 sent /reports/monthly to show with id "monthly", because :id accepts any segment. Set 2 declared the custom
route first and reached reports#monthly. Set 3 kept the order and added a
route constraint: A test a route adds to its verb and path: a regular expression for one segment, or a lambda or object for the whole request. The route fits only when the test passes, so the router moves on otherwise., constraints: { id: /\d+/ }, so show refuses every id that is not
digits: /reports/monthly fell through to the custom route, and /reports/june fitted nothing at all. A running app
answers that RoutingError with 404, as the Configuring guide’s table of exception statuses lists.
A regular expression tests one segment. A lambda receives the whole request, so it can test what no path shows: in
the fourth part of the run, only pune.kaksha.example, a centre’s own subdomain, reached the dashboard. The same order
rule is why the guide puts root at the top of the file, so the page most visitors ask for is matched first.
Fixing the order and stopping there
Moving the custom route up works until someone adds the next one below resources. A constraint writes the rule into the route itself, so prefer it when ids are numbers, and keep a routing test that asserts where /reports/monthly goes.
Checkpoint
Check: First match, order and constraints
Member and collection routes add actions, sparingly
Payments need two actions that the seven do not cover: refunding one payment and listing the overdue ones. A member route acts on one record and a collection route on the set, and each adds a route and a helper:
Declared inside resources :payments |
Route and helper | Acts on |
|---|---|---|
member do post :refund end |
POST /payments/:id/refund, refund_payment_path(payment) |
one payment |
collection do get :overdue end |
GET /payments/overdue, overdue_payments_path |
the whole list |
The second table of the session above lists both rows, ahead of index and show, because resources runs its block
before it adds its own routes.
Choose the verb by what the action does. A refund changes data, so it is POST; the overdue list only reads, so it is GET. HTTP defines GET as a safe method: following a link, prefetching or reloading may repeat it, and nothing should change on the server when that happens.
Many extra actions hide a resource
The Routing guide’s advice, when a resource keeps gaining extra actions, is to ask whether another resource is
hiding in them. If refunds grow states and a history of their own, resources :refunds nested under payments gives
them index, create and show, and PaymentsController stays small.
Checkpoint
Check: Member and collection routes
Routes that are not resources: root, redirects and /up
Three lines of Kaksha’s file declare no resource. root "batches#index" names what / shows. get "/courses/:id", to: redirect("/batches/%{id}") keeps the links of an older version of Kaksha working by sending them to the new URL.
get "up" => "rails/health#show" is the health check every new Rails app gets: its generated comment says /up answers
200 when the app boots without exceptions and 500 otherwise, for load balancers and uptime monitors.
A route set is itself a Rack application, so this program hands it requests directly, with no server, and prints what came back:
# A route set is a Rack application: hand it a request and it answers, so this program needs no server. It draws the
# routes of Kaksha that are not resources (the root, two redirects and the health check of every new app) and sends
# each one a GET, printing the status, the Location header of a redirect and what the body holds.
require "action_controller/railtie"
class BatchesController < ActionController::Base
def index = render(plain: "the batch list")
def show = render(plain: "batch #{params[:id]}")
end
routes = ActionDispatch::Routing::RouteSet.new
helpers = routes.url_helpers
routes.default_url_options = { host: "kaksha.example" }
puts "before root is drawn: root_url defined? #{helpers.method_defined?(:root_url)}"
routes.draw do
root "batches#index"
resources :batches, only: [:index, :show]
get "/courses/:id", to: redirect("/batches/%{id}")
get "/fees", to: redirect("/payments", status: 302)
get "up" => "rails/health#show", as: :rails_health_check
end
puts "after root is drawn: root_url defined? #{helpers.method_defined?(:root_url)} -> #{helpers.root_url}"
puts
%w[/ /courses/7 /courses/7?page=2 /fees /up].each do |path|
status, headers, body = routes.call(Rack::MockRequest.env_for("http://kaksha.example#{path}"))
text = +""
body.each { |part| text << part }
body.close if body.respond_to?(:close)
line = "GET #{path.ljust(17)} #{status}"
line << " location: #{headers['location']}" if headers["location"]
if text.empty?
# a redirect carries no body worth showing
elsif headers["content-type"].to_s.start_with?("text/html")
line << " an HTML page of #{text.bytesize} bytes"
else
line << " body: #{text.inspect}"
end
puts line
end Output
before root is drawn: root_url defined? false after root is drawn: root_url defined? true -> http://kaksha.example/ GET / 200 body: "the batch list" GET /courses/7 301 location: http://kaksha.example/batches/7 GET /courses/7?page=2 301 location: http://kaksha.example/batches/7 GET /fees 302 location: http://kaksha.example/payments GET /up 200 an HTML page of 73 bytes
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby redirects_and_root.rb
The course link answered 301 Moved Permanently, the default of redirect, and the Routing guide warns that
browsers and proxies may cache a 301, so keep it for a move you will not undo. The fee link, which the program
declares with status: 302, answered 302. The second course request also shows that its query string was dropped: %{id}
rebuilt the path from the path parameters alone, so a redirect that must keep ?page=2 takes a block, which receives
the request.
The first two lines of the output matter for sign-in. The generated authentication code sends a visitor who signs in
to after_authentication_url, which returns the page they first asked for or else root_url, and the run printed
that root_url does not exist until a root route is drawn.
Checkpoint
Check: The root, redirects and the health check
Path helpers for pages, URL helpers for anything that leaves the request
Every named route gives two route helper: A method Rails generates from a named route, such as batch_path or batch_url, that builds the route's path or full URL so controllers, views and mailers never write a URL by hand.. batch_path(7) returns the path,
/batches/7, which a link on a Kaksha page can use because the browser already knows the host. batch_url(7) returns
the whole URL, scheme and host included. This program calls the helpers of the batches, the shallow enrollments and a
singular basket, with no request anywhere:
# Every named route gives two helpers: name_path builds the path alone, name_url the whole URL, host included.
# The helpers below come from Kaksha's batches, its shallow enrollments and a singular basket, drawn into one route
# set; url_helpers is the module that controllers, views and mailers include. Nothing here needs a request.
require "action_controller/railtie"
require "active_model"
routes = ActionDispatch::Routing::RouteSet.new
routes.draw do
resources :batches do
resources :enrollments, shallow: true
end
resource :basket, only: :show
direct(:fee_guide) { "https://help.kaksha.example/fees" }
resolve("Basket") { [:basket] }
end
links = routes.url_helpers
puts "batches_path #{links.batches_path}"
puts "batch_path(7) #{links.batch_path(7)}"
puts "batch_enrollments_path(7) #{links.batch_enrollments_path(7)}"
puts "enrollment_path(31) #{links.enrollment_path(31)}"
begin
links.batch_url(7)
rescue ArgumentError => error
puts "batch_url(7), no host set #{error.class}: #{error.message[/\A[^!]*!/]}"
end
routes.default_url_options = { host: "kaksha.example", protocol: "https" }
puts "batch_url(7), host set #{links.batch_url(7)}"
# A record is turned into its id segment by to_param, which a model may change.
class Batch
include ActiveModel::Model
attr_accessor :id, :name
def to_param = "#{id}-#{name.parameterize}"
def persisted? = true
end
june = Batch.new(id: 7, name: "Rails June Weekend")
puts "batch_path(june) #{links.batch_path(june)}"
puts "an Integer id cast from it #{ActiveModel::Type::Integer.new.cast('7-rails-june-weekend')}"
# direct names a URL that is not a route; resolve changes where a model's URL points.
class Basket
include ActiveModel::Model
def persisted? = true
end
puts "fee_guide_url #{links.fee_guide_url}"
puts "polymorphic_path(Basket.new) #{links.polymorphic_path(Basket.new)}" Output
batches_path /batches batch_path(7) /batches/7 batch_enrollments_path(7) /batches/7/enrollments enrollment_path(31) /enrollments/31 batch_url(7), no host set ArgumentError: Missing host to link to! batch_url(7), host set https://kaksha.example/batches/7 batch_path(june) /batches/7-rails-june-weekend an Integer id cast from it 7 fee_guide_url https://help.kaksha.example/fees polymorphic_path(Basket.new) /basket
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby route_helpers.rb
With no request to take a host from, batch_url(7) raised ArgumentError: Missing host to link to! A mailer is always
in that position: the Action Mailer guide notes that it knows nothing of an incoming request, asks for
config.action_mailer.default_url_options with the host, and recommends _url helpers in emails because a mail
client has no base address to complete a path. Once the host was set, the same call gave
https://kaksha.example/batches/7.
| Helper | Returns | Use it in |
|---|---|---|
batch_path(7) |
/batches/7 | links, forms and redirects on pages |
batch_url(7) |
https://kaksha.example/batches/7 | emails, feeds and anything read elsewhere |
A record passed to a helper becomes its URL segment through to_param, which returns the id unless the model says
otherwise. The Batch here returned "7-rails-june-weekend", and an Integer attribute casts that string back to 7, so a
lookup by id still finds batch 7. Two more methods add helpers: direct names a URL that no route matches, as
fee_guide_url did, and resolve tells polymorphic_path and form_with where a model lives, which sent the
singular Basket to /basket.
Key idea
A path is enough inside a page the browser already has; anything read somewhere else needs the whole URL, with a host that comes from configuration.
Checkpoint
Check: Path helpers, URL helpers and the host
Practise: read a routes file and predict the route
Every question below comes from Kaksha’s routes file and the tables and runs recorded on this page. Answer one and its explanation opens at once:
Practice that adapts to you
Practise this lesson
14 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: resources :batches declares routes for seven actions. Name them, with the verb and path each answers.
index answers GET /batches and create answers POST /batches. new is GET /batches/new, the form for a new batch. show
is GET /batches/:id, edit is GET /batches/:id/edit, update answers both PATCH and PUT on /batches/:id, and destroy
answers DELETE /batches/:id. That is four URL shapes for seven actions, because the verb tells them apart, and eight
rows in bin/rails routes, because update has two verbs. A singular resource such as resource :session drops index
and the :id segment.
Three questions in the module’s bank go further: a route that takes requests meant for another, links in an email that open nothing, and a routes file nested three levels deep.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
In Kaksha, GET /reports/monthly reaches ReportsController#show with params[:id] = "monthly", and the page says no report was found. The routes file declares resources :reports and, below it, get "reports/monthly". Explain what happened, fix it two ways, and say which fix you would keep.
-
Fresher to mid
Kaksha's password-reset email builds its link with edit_password_path(token). Teachers report that the link in the email does not open the reset page, although the same helper works on the website. Explain the cause and the fix, and how every environment gets links to its own host.
-
Mid to senior
Kaksha nests payments inside enrollments inside batches, and refunds inside payments, so the list of a payment's refunds needs batch_enrollment_payment_refunds_path(batch, enrollment, payment), and payments also carry accept, decline and reopen routes for refund requests. Propose a cleaner routes file and say how you would change it without breaking links already sent.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which actions does resources :batches declare, and on how many URL shapes?
index, new, create, show, edit, update and destroy, on four shapes: /batches, /batches/new, /batches/:id and /batches/:id/edit. The verb tells the actions on one shape apart, and update answers both PATCH and PUT.
-
How does a singular resource such as resource :session differ from resources?
It declares no index action and leaves the :id segment out of every path.
-
What does shallow: true change in a nested resource?
Routes that list or create children keep the parent in the URL, such as /batches/7/enrollments, while routes for one existing child get their own short URL, such as /enrollments/31. Nest one level at most.
-
How does namespace :admin differ from scope "(:locale)" in routes.rb?
A namespace moves the path, the helper names and the controller into a module. A scope like "(:locale)" changes only the path.
-
A custom route declared below resources :batches never reaches its action. Why, and what fixes it?
The router takes the first route that fits, and show's /batches/:id fits first. Declare the custom route above resources, or give show a constraint, such as ids made only of digits, so it refuses the request.
-
How do you add an odd action to a resource, and how do you keep an old URL working?
Add a member route for one record or a collection route for the list, with the verb that matches what it does. A redirect in routes.rb sends old links on with a 301 unless you give another status.
-
When do you need batch_url rather than batch_path?
For anything read outside the current page, such as an email. A path is enough inside a page the browser already has; URL helpers take their host from default_url_options.
Key takeaways
resources :batchesdeclares seven actions on four URL shapes, with update reached by PATCH and by PUT, and a singularresource :sessionhas no index and no :id segment.- Nest one level at most, let
shallow: truegive routes for one existing record their own short URLs, and share nested routes between resources with a routing concern. - A namespace moves the path, the helper names and the controller into a module, while a scope such as
"(:locale)"moves only the path. - The router takes the first route that fits, so a custom route below
resourcesloses to show unless it is declared first or a constraint such as{ id: /\d+/ }makes show refuse it. - Member and collection routes add the odd action with the verb that matches what it does, and
redirectkeeps old links working with a 301 unless another status is given. - Path helpers serve pages, URL helpers serve emails and anything read elsewhere, and their host comes from
default_url_options.
Terms in this lesson
- resourceful route
- A set of routes declared by one resources line (seven actions on four URL shapes) or one singular resource line (no index and no id), each pairing an HTTP verb and a path with a controller action.
- route constraint
- A test a route adds to its verb and path: a regular expression for one segment, or a lambda or object for the whole request. The route fits only when the test passes, so the router moves on otherwise.
- route helper
- A method Rails generates from a named route, such as batch_path or batch_url, that builds the route's path or full URL so controllers, views and mailers never write a URL by hand.
- routing concern
- A named block of routes declared once with concern and added to several resources with concerns:, so routes that more than one resource needs, such as nested reviews, are written in one place.
- shallow route
- A route of a nested resource that leaves the parent out of the URL once the record has its own id, so /enrollments/31 serves show, edit, update and destroy while the list stays under its batch.
References
- Rails Routing from the Outside In (Ruby on Rails)
- ActionDispatch::Routing::Mapper::Resources (Ruby on Rails)
- ActionDispatch::Routing::Mapper::CustomUrls (Ruby on Rails)
- Action Mailer Basics (Ruby on Rails)
- Ruby on Rails Security Guide (Ruby on Rails)
- Configuring Rails Applications (Ruby on Rails)
- This Week in Rails: Rails World recordings are online, new minimum Ruby version and more (Ruby on Rails)
- RFC 9110: HTTP Semantics (IETF)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress